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
)합니다. 마지막으로 우리가 함수의 인자로 넘겨받은 filename
은 LOAD_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_LIST
만 BUILD_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 a
가 True
일 때에는 실행이 되어선 안됩니다. 따라서 Python 3.6까지는 UNARY_NOT
으로 한 번 뒤집어주고 POP_JUMP_IF_FALSE
로 if문을 건너뛰도록 설계되어 있었지만 Python 3.7부터는 다음과 같이 약간 더 효율적인 바이트코드가 생성(UNARY_NOT
+ POP_JUMP_IF_FALSE
를 POP_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,