Peephole: CPython은 어떻게 코드를 최적화하는가

Peephole: CPython은 어떻게 코드를 최적화하는가

많은 스크립트 언어는 "실행 성능(속도)"이 좋지 않다는 큰 단점을 지니고 있습니다. 이를 해결하기 위해 JIT 런타임을 붙이거나 개발자 스스로 코드를 최적화하기도 하지만 개발자 스스로 코드를 최적화한다고 하여(좋은 코드를 작성했다고 했을 때) 투자하는 시간에 비해 큰 성능향상을 얻기는 어렵습니다. Python은 이러한 문제를 조금이나마 해소시키고자 Peephole이라는 Python 바이트코드 최적화를 위한 구현을 두고 있으며 이는 Python 내부적으로 효율적인 작동을 보장해주고 있습니다.

dis: Python 바이트코드

  • Python에는 CPython 내부 머신코드를 disassemble하여 바이트코드로 보여주는 dis 모듈이 존재합니다. 이 모듈을 통해 Python이 내부적으로 어떻게 작동하는지를 이해할 수 있으며(Include/opcode.h) 더 나아가 복잡하고 어려운 코드를 최적화하는데도 도움이 될 수 있습니다. 또한 이를 이용해 멋진 ORM을 만들어낼 수도 있습니다.

간단하게 시작해봅시다. 다음 코드는 내부적으로 어떻게 돌아갈까요?

def exists(filename: str) -> bool:
     return os.path.exists(filename)

위 코드의 바이트코드를 확인해볼까요? func.__code__.co_code를 통해 실제 바이트코드를 확인할 수 있습니다.

In [19]: exists.__code__
Out[19]: <code object exists at 0x7f5644025f60, file "<ipython-input-5-0dc3deb969f7>", line 1>

In [20]: exists.__code__.co_code
Out[20]: b't\x00\x00j\x01\x00j\x02\x00|\x00\x00\x83\x01\x00S'

In [21]: [x for x in exists.__code__.co_code]
Out[21]: [116, 0, 0, 106, 1, 0, 106, 2, 0, 124, 0, 0, 131, 1, 0, 83]

자 바이트코드를 확인했습니다. 어라 그런데 저걸 어떻게 읽을까요? 위에서 결과로 나온 [116, 0, 0, 106, 1, 0, 106, 2, 0, 124, 0, 0, 131, 1, 0, 83] 리스트는 Python이 내부적으로 읽을 수 있는 형태의 바이트코드입니다. 위에 링크로 걸어둔 Include/opcode.h 파일에 있는대로 추적해서 116 -> LOAD_GLOBAL, 106 -> LOAD_ATTR.. 이라는 것을 알아낼 수는 있지만 사람이 직접 알아내기는 여전히 어렵습니다.

자 그러면 다른 방법을 이용해봅시다. 위 코드에 대해 dis.dis(exists)를 실행해봅시다.

  2           0 LOAD_GLOBAL              0 (os)
              3 LOAD_ATTR                1 (path)
              6 LOAD_ATTR                2 (exists)
              9 LOAD_FAST                0 (filename)
             12 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             15 RETURN_VALUE

훨씬 보기 쉬운 결과가 나왔네요! 얼추 읽기 쉬워보입니다.

제일 왼쪽에 위에 보이는 2는 실제 코드가 시작하는 라인의 번호입니다. 다음 2번째 컬럼으로 있는 숫자는 바이트코드의 offset이며 그 다음은 OPCODE, 그 다음은 arg를 나타냅니다. 위 코드를 예로 볼 때 위 코드에서는 실행되는 과정에서 가장 먼저 GLOBAL(전역)로부터 os 변수가 있는지 찾습니다(LOAD_GLOBAL). 다음 os변수 내에 path 속성이 있는지 확인(LOAD_ATTR) -> os.path 내에 exists 속성이 있는지 확인(LOAD_ATTR)합니다. 마지막으로 우리가 함수의 인자로 넘겨받은 filenameLOAD_FAST OP를 통해 불러와서 os.path.exists 함수를 호출하고 가져온 값을 반환하게 됩니다.

Python의 dis 모듈은 어떻게 바이트코드를 해독해내는 걸까요? 이는 CPython Include/opcode.h에 나와있는 바이트코드 맵대로 Lib/opcode.py에 다음과 같이 바이트코드 사전을 만들어두었기 때문입니다.

In [23]: import opcode
In [24]: opcode
Out[24]: <module 'opcode' from '/usr/lib/python3.5/opcode.py'>
In [25]: opcode.opmap
Out[25]:
{'BEFORE_ASYNC_WITH': 52,
 'BINARY_ADD': 23,
 'BINARY_AND': 64,
 'BINARY_FLOOR_DIVIDE': 26,
 'BINARY_LSHIFT': 62,
 'BINARY_MATRIX_MULTIPLY': 16,
 'BINARY_MODULO': 22,
 'BINARY_MULTIPLY': 20,
 'BINARY_OR': 66,
 'BINARY_POWER': 19,
 'BINARY_RSHIFT': 63,
 ...
 }

Peephole optimizer: Python 바이트코드 최적화 구현

Python 바이트코드가 Peephole에 의해 어떻게 바뀌는지, 왜 그렇게 바뀌는지에 대해 자세히 알아보고 싶었지만 현재 Python 버전에서 공식적으로 Peephole optimizations을 완전히 비활성화 하는 방법이 없기 때문에(관련 논의는 여기) 간단하게 알려진 내용에 대해서만 작성해보겠습니다.

Constant folding

Constant folding은 peephole이 가장 많이 최적화하는 부분입니다. 런타임에서 비효율적인 계산 과정을 생략할 수 있도록 유도하고 있으며 이를 통해 줄여지는 바이트코드의 양은 상당한 편입니다.

unary operators, binary operations

런타임에서 +a, -a, ~a와 같은 코드를 최적화하여 constant로 구성합니다.

즉 아래와 같은 코드는

def func():
    return ~1

최적화되지 않은 상태에는 다음과 같은 바이트코드가 나오지만:

  1           0 LOAD_CONST               1 (1)
              2 UNARY_INVERT
              4 RETURN_VALUE

최적화된 상태에서는 다음과 같은 바이트코드가 나오게 됩니다:

  1           0 LOAD_CONST               2 (-2)
              3 RETURN_VALUE

binary operations 최적화의 경우 다른 두 상수간의 +, -, *, /, //, %, ** 연산을 동일한 방식으로 최적화합니다.

BUILD_TUPLE: tuple

Python에서 튜플은 크기가 정해져있고 변하지 않기 때문에 튜플 또한 Peephole의 최적화 대상입니다. a = (1, 2, 3, 4, )와 같은 튜플이 있다면 이를 내부적으로 변수(variable) -> 상수(constant)로 변환하는 과정을 거치게 됩니다. 즉 최적화되지 않은 상태의 바이트코드는 다음과 같이 나오지만:

  2           0 LOAD_CONST               1 (1)
              2 LOAD_CONST               2 (2)
              4 LOAD_CONST               3 (3)
              6 LOAD_CONST               4 (4)
              8 BUILD_TUPLE              4
             10 STORE_FAST               0 (a)
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

최적화 후에는 다음과 같이 바뀌게 됩니다:

  2           0 LOAD_CONST               5 ((1, 2, 3, 4))
              3 STORE_FAST               0 (a)
              6 LOAD_CONST               0 (None)
              9 RETURN_VALUE

이 내용은 tuple에만 한정되는 것이 아닙니다. Peephole은 "변경되지 않을 list"를 찾아 내부적으로 하나의 튜플로 전환하기도 합니다. 예로 if number in [1, 2]: pass라는 코드가 있을 때 [1, 2]if문에 직접적으로 쓰여졌기 때문에 런타임에서 변경될 수 없는 부분입니다. 이 코드의 최적화되지 않은 바이트코드는 다음과 같습니다:

  1           0 LOAD_NAME                0 (number)
              2 LOAD_CONST               0 (1)
              4 LOAD_CONST               1 (2)
              6 BUILD_LIST               2
              8 COMPARE_OP               6 (in)
             10 POP_JUMP_IF_FALSE       12
        >>   12 LOAD_CONST               2 (None)
             14 RETURN_VALUE

이 바이트코드는 Peephole을 거치면 다음과 같이 바뀌게 됩니다:

  1           0 LOAD_NAME                0 (number)
              3 LOAD_CONST               3 ((1, 2))
              6 COMPARE_OP               6 (in)
              9 POP_JUMP_IF_FALSE       12
        >>   12 LOAD_CONST               2 (None)
             15 RETURN_VALUE

즉, 이미 성능을 염두해두고 고정적으로 쓰이는 배열을 tuple로 사용했던 분들이 많이 계셨겠지만 list로 써도 Python이 내부적으로 바이트코드 최적화를 통해 tuple로 바꿔주는 역할을 해주고 있었던 것입니다.

Set -> Frozenset

set에 대해서도 동일한 작업을 진행합니다. 변경될 수 없는 곳에 존재하는 set(mutable)을 frozenset(immutable)로 바꾸는 역할을 하죠. 위에서 썼던 코드를 if number in {1, 2}: pass로 바꿔서 다시 확인봅시다. 우선 최적화되지 않은 바이트코드입니다.

  1           0 LOAD_NAME                0 (number)
              2 LOAD_CONST               0 (1)
              4 LOAD_CONST               1 (2)
              6 BUILD_SET                2
              8 COMPARE_OP               6 (in)
             10 POP_JUMP_IF_FALSE       12
        >>   12 LOAD_CONST               2 (None)
             14 RETURN_VALUE

BUILD_LISTBUILD_SET으로 바뀌었을 뿐 동일한 바이트코드가 나왔습니다. 이제 최적화된 바이트코드입니다.

  1           0 LOAD_NAME                0 (number)
              3 LOAD_CONST               3 (frozenset({1, 2}))
              6 COMPARE_OP               6 (in)
              9 POP_JUMP_IF_FALSE       12
        >>   12 LOAD_CONST               2 (None)
             15 RETURN_VALUE

최적화된 바이트코드에서 우리는 자료형이 set에서 fronzenset으로 바뀐 것을 확인할 수 있습니다.

이 구현에서 중요한 부분이 있습니다. 이 최적화 구현은 Python 3.2부터 제공되기 시작했고 Python 2에는 구현되지 않았기 때문에 Python 3.2 이상 버전을 사용해야만 이 최적화 혜택을 누릴 수 있습니다(?). 네, Python 3 씁시다. (뿐만 아니라 계속해서 개선되고있는 Peephole 코드는 Python 3에서만 적용되고 있습니다)

COMPARE_OP

In [12]: def compare(a: Any, b: Any) -> bool:
    ...:     return not(a is b)
    ...:

In [13]: dis.dis(compare)
  2           0 LOAD_FAST                0 (a)
              3 LOAD_FAST                1 (b)
              6 COMPARE_OP               9 (is not)
              9 RETURN_VALUE

not(a is b)와 같은 코드는 a is not b와 동일한 바이트코드로, not(a is not b)와 같은 코드는 a is b로, not(a in b)a in b, not(a not in b)a in b로 최적화합니다. 원래대로라면 아래와 같이 COMPARE_OP를 먼저 실행하고 UNARY_NOT으로 NOT 연산을 실행하는 바이트코드가 나오게 됩니다.

  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 COMPARE_OP               8 (is)
              6 UNARY_NOT
              8 RETURN_VALUE

Python 3.7부터 적용되는 내용(bpo-30501: Make the compiler producing optimized code for condition expressions)에 따르면 if not a and b: x와 같은 코드는 원래 다음과 같은 바이트코드가 나오는데:

  1           0 LOAD_NAME                0 (a)
              2 UNARY_NOT
              4 POP_JUMP_IF_FALSE       14
              6 LOAD_NAME                1 (b)
              8 POP_JUMP_IF_FALSE       14
             10 LOAD_NAME                2 (x)
             12 POP_TOP
        >>   14 LOAD_CONST               0 (None)
             16 RETURN_VALUE

위 코드의 경우 and 조건이 붙고 not a && b 이기 때문에 앞에 있는 조건인 not aTrue일 때에는 실행이 되어선 안됩니다. 따라서 Python 3.6까지는 UNARY_NOT으로 한 번 뒤집어주고 POP_JUMP_IF_FALSE로 if문을 건너뛰도록 설계되어 있었지만 Python 3.7부터는 다음과 같이 약간 더 효율적인 바이트코드가 생성(UNARY_NOT + POP_JUMP_IF_FALSEPOP_JUMP_IF_TRUE로 변환)됩니다:

  1           0 LOAD_NAME                0 (a)
              2 POP_JUMP_IF_TRUE        12
              4 LOAD_NAME                1 (b)
              6 POP_JUMP_IF_FALSE       12
              8 LOAD_NAME                2 (x)
             10 POP_TOP
        >>   12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

NOP instructions 제거

예로 다음 코드를 봅시다.

def test():
    if a:
        return 1
    else:
        return 2

위 코드의 최적화되지 않은 바이트코드는 다음과 같습니다:

  2           0 LOAD_GLOBAL              0 (a)
              2 POP_JUMP_IF_FALSE       10

  3           4 LOAD_CONST               1 (1)
              6 RETURN_VALUE
              8 JUMP_FORWARD             4 (to 14)

  5     >>   10 LOAD_CONST               2 (2)
             12 RETURN_VALUE
        >>   14 LOAD_CONST               0 (None)
             16 RETURN_VALUE

눈에 띄는 부분이 있나요? 바로8번 OP인 JUMP_FORWARD입니다. Python 인터프리터가 처음 바이트코드를 생성해낼 때는 if문 안에 있는 코드를 실행하고 if문 바깥으로 나가도록 유도하지만 실제로는 RETURN_VALUE가 실행되어 값이 반환되기 때문에 JUMP_FORWARD OP는 아예 필요가 없습니다. 따라서 Peephole은 다음과 같이 바이트코드를 최적화합니다:

  2           0 LOAD_GLOBAL              0 (a)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE

  5     >>   10 LOAD_CONST               2 (2)
             13 RETURN_VALUE
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE

즉, pass나 불필요하게 생성된 바이트코드나 no-op를 제거하는 역할 또한 Peephole이 맡고있는 셈입니다. 실제 Python/peephole.c 파일에서 이와 관련된 코드를 찾을 수 있었습니다:

...
/* Remove unreachable ops after RETURN */
case RETURN_VALUE:
    h = i + 1;
    while (h < codelen && ISBASICBLOCK(blocks, i, h)) {
        h++;
    }
    if (h > i + 1) {
        fill_nops(codestr, i + 1, h);
        nexti = find_op(codestr, h);
    }
    break;
...

컴파일러가 런타임에 알아서 바이트코드를 최적화해주는 부분은 해당 컴파일러를 사용하는 사용자가 힘을 들여 코드를 최적화하지 않아도 내부적으로 간단한 최적화를 해주기 때문에 굉장히 멋지고 아름다운 부분입니다. 이러한 과정을 통해 Python은 매 릴리즈마다 작지만 충분한 성능 향상을 이루고 있고 이는 Python 3.7에 와서 Python 2.7보다 더이상 성능이 뒤쳐지지 않도록 하는 결과를 만들었습니다.

이 글에서 설명하는 모든 내용은 Python/peephole.c 파일에서 확인할 수 있으며, 코드가 매우 읽기 쉽게 구성되어 있으므로 한 번 쯤 읽어보는 것을 추천드립니다.

최적화되지 않은 Python 바이트코드는 https://github.com/python/cpython.git 레포를 클론한 후 다음 패치를 적용하면 확인할 수 있습니다: (HEAD: 7028e5986f)

diff --git i/Python/compile.c w/Python/compile.c
index 78e797a2c3..cbaa88d4dd 100644
--- i/Python/compile.c
+++ w/Python/compile.c
@@ -5325,10 +5325,6 @@ makecode(struct compiler *c, struct assembler *a)
     if (flags < 0)
         goto error;

-    bytecode = PyCode_Optimize(a->a_bytecode, consts, names, a->a_lnotab);
-    if (!bytecode)
-        goto error;
-
     tmp = PyList_AsTuple(consts); /* PyCode_New requires a tuple */
     if (!tmp)
         goto error;
@@ -5339,7 +5335,7 @@ makecode(struct compiler *c, struct assembler *a)
     kwonlyargcount = Py_SAFE_DOWNCAST(c->u->u_kwonlyargcount, Py_ssize_t, int);
     co = PyCode_New(argcount, kwonlyargcount,
                     nlocals_int, stackdepth(c), flags,
-                    bytecode, consts, names, varnames,
+                    a->a_bytecode, consts, names, varnames,
                     freevars, cellvars,
                     c->c_filename, c->u->u_name,
                     c->u->u_firstlineno,