PEP 709 – 인라인 컴프리헨션
- Author:
- Carl Meyer <carl at oddbird.net>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 24-Feb-2023
- Python-Version:
- 3.12
- Post-History:
- 25-Feb-2023
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
컴프리헨션은 현재 중첩 함수로 컴파일됩니다. 이 방식은 컴프리헨션의 반복 변수를 격리해 주지만, 런타임에는 비효율적입니다. 이 PEP는 리스트, 딕셔너리, 집합 컴프리헨션을 해당 컴프리헨션이 정의된 코드 안에 인라인하고, 충돌하는 지역 변수를 스택에 푸시/팝하여 기대되는 격리를 제공할 것을 제안합니다. 이 변경으로 컴프리헨션이 훨씬 빨라집니다. 컴프리헨션만 측정하는 마이크로벤치마크에서는 최대 2배 빨라지며, 실제 작업을 수행하면서 컴프리헨션을 많이 사용하는 실제 코드에서 가져온 한 샘플 벤치마크에서는 11%의 속도 향상으로 이어집니다.
동기
컴프리헨션은 Python 언어에서 인기가 많고 널리 사용되는 기능입니다. 컴프리헨션을 중첩 함수로 컴파일하는 방식은 사용자 코드의 성능을 희생하여 컴파일러의 단순성을 추구합니다. 컴파일러 복잡도를 조금만 늘리면, 거의 동일한 의미(Backwards Compatibility 참조)를 유지하면서 컴프리헨션을 사용하는 모든 사용자에게 훨씬 나은 런타임 성능을 제공할 수 있습니다.
근거
인라인은 많은 언어에서 흔히 쓰이는 컴파일러 최적화입니다. Python에서는 호출 대상이 런타임에 패치될 수 있으므로, 컴파일 시점에 함수 호출을 일반적으로 인라인하는 것은 거의 불가능합니다. 컴프리헨션은 특수한 경우입니다. 컴파일러가 호출 대상을 정적으로 알고 있으며, 이 호출 대상은 패치될 수도 없고(바이트코드를 직접 조작하는, 문서화되지 않고 지원되지 않는 방법을 제외하면) 외부로 빠져나갈 수도 없습니다.
인라인하면 바이트코드에 대한 다른 컴파일러 최적화도 더 효과적으로 동작합니다. 컴프리헨션 바이트코드가 더 이상 불투명한 호출이 아니므로 이러한 최적화가 그 안을 “들여다볼” 수 있기 때문입니다.
보통 성능 개선에는 PEP가 필요하지 않습니다. 하지만 이 경우에는 가장 단순하고 효율적인 구현이 사용자에게 보이는 몇 가지 영향을 낳으므로, 이는 단순한 성능 개선이 아니라 언어에 대한 (작은) 변경입니다.
명세
다음과 같은 간단한 컴프리헨션이 있다고 합시다.:
def f(lst):
return [x for x in lst]
현재 컴파일러는 함수 f에 대해 다음 바이트코드를 생성합니다:
1 0 RESUME 0
2 2 LOAD_CONST 1 (<code object <listcomp> at 0x...)
4 MAKE_FUNCTION 0
6 LOAD_FAST 0 (lst)
8 GET_ITER
10 CALL 0
20 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x...>:
2 0 RESUME 0
2 BUILD_LIST 0
4 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 4 (to 18)
10 STORE_FAST 1 (x)
12 LOAD_FAST 1 (x)
14 LIST_APPEND 2
16 JUMP_BACKWARD 6 (to 6)
>> 18 END_FOR
20 RETURN_VALUE
컴프리헨션의 바이트코드는 별도의 코드 객체에 들어 있습니다. f()가 호출될 때마다 새로운 일회용 함수 객체가 (MAKE_FUNCTION으로) 할당되고, 호출된 다음(Python 스택에 새 프레임을 할당했다가 파괴합니다) 곧바로 버려집니다.
이 PEP에서는 컴파일러가 f()에 대해 대신 다음 바이트코드를 생성합니다:
1 0 RESUME 0
2 2 LOAD_FAST 0 (lst)
4 GET_ITER
6 LOAD_FAST_AND_CLEAR 1 (x)
8 SWAP 2
10 BUILD_LIST 0
12 SWAP 2
>> 14 FOR_ITER 4 (to 26)
18 STORE_FAST 1 (x)
20 LOAD_FAST 1 (x)
22 LIST_APPEND 2
24 JUMP_BACKWARD 6 (to 14)
>> 26 END_FOR
28 SWAP 2
30 STORE_FAST 1 (x)
32 RETURN_VALUE
더 이상 별도의 코드 객체도 없고, 일회용 함수 객체를 생성하지도 않으며, Python 프레임을 생성하고 파괴할 필요도 없습니다.
x 반복 변수의 격리는 두 명령어의 조합으로 이루어집니다. 하나는 오프셋 6에 있는 새로운 LOAD_FAST_AND_CLEAR 옵코드로, 컴프리헨션을 실행하기 전에 x의 바깥 값을 스택에 저장합니다. 다른 하나는 30 STORE_FAST로, 컴프리헨션을 실행한 후 x의 바깥 값을 (있는 경우) 복원합니다.
컴프리헨션이 바깥 스코프의 변수에 접근하는 경우, 인라인하면 이러한 변수를 셀에 넣을 필요가 없어집니다. 따라서 컴프리헨션(과 바깥 함수의 다른 모든 코드)이 이 변수들에 일반적인 fast 지역 변수로 접근할 수 있습니다. 이를 통해 성능이 더 향상됩니다.
경우에 따라 컴프리헨션 반복 변수는 바깥 스코프에서 단순한 함수 지역 변수가 아니라 전역 변수, cellvar 또는 freevar일 수 있습니다. 이러한 경우에는 의미가 유지되도록 컴파일러가 컴프리헨션에 들어가고 나올 때 해당 변수의 스코프 정보도 내부적으로 푸시하고 팝합니다. 예를 들어 변수가 컴프리헨션 바깥에서 전역 변수라면, 컴프리헨션 바깥에서 그 변수를 참조하는 곳에서는 여전히 LOAD_GLOBAL이 사용되지만, 컴프리헨션 안에서는 LOAD_FAST / STORE_FAST가 사용됩니다. 변수가 컴프리헨션 바깥에서 cellvar/freevar라면, 그 변수를 저장/복원하는 데 사용하는 LOAD_FAST_AND_CLEAR / STORE_FAST는 달라지지 않습니다(LOAD_DEREF_AND_CLEAR는 없습니다). 즉, 셀 안의 값만이 아니라 셀 전체가 저장/복원되므로 컴프리헨션이 바깥 셀에 값을 쓰지 않습니다.
모듈 스코프나 클래스 스코프에 있는 컴프리헨션도 인라인됩니다. 이 경우 컴프리헨션은 원래라면 LOAD_NAME / STORE_NAME만 사용될 스코프에서, 컴프리헨션 안에서만 컴프리헨션 반복 변수에 fast 지역 변수(LOAD_FAST / STORE_FAST)를 사용하도록 하여 격리를 유지합니다.
사실상 컴프리헨션은 지역 변수가 완전히 격리되는 하위 스코프를 도입하지만, 호출에 따르는 성능 비용이나 스택 프레임 진입은 발생하지 않습니다.
현재 이 PEP의 참조 구현에서는 제너레이터 표현식을 인라인화하지 않습니다. 앞으로는 반환된 제너레이터 객체가 외부로 유출되지 않는 경우에 일부 제너레이터 표현식이 인라인화될 수 있습니다.
비동기 컴프리헨션은 동기 컴프리헨션과 똑같이 인라인화되며, 특별한 처리가 필요하지 않습니다.
하위 호환성
컴프리헨션 인라인화는 다음과 같이 눈에 보이는 동작 변경을 일으킵니다. 구현의 이러한 변경에 맞추기 위해 표준 라이브러리나 테스트 스위트를 변경할 필요가 전혀 없었으며, 이는 사용자 코드에 미치는 영향도 미미할 가능성이 높다는 것을 시사합니다.
컴파일러 바이트코드 출력의 문서화되지 않은 세부 사항에 의존하는 특수 도구는 물론 아래에 설명된 것 이상으로 영향을 받을 수 있지만, 이러한 도구는 이미 Python 버전마다 바이트코드 변경에 적응해야 합니다.
locals()가 바깥쪽 변수를 포함합니다
컴프리헨션 안에서 locals()를 호출하면 컴프리헨션을 포함하는 함수의 모든 지역 변수가 포함됩니다. 예를 들어 다음과 같은 함수가 있을 때:
def f(lst):
return [locals() for x in lst]
현재 Python에서 f([1])를 호출하면 다음을 반환합니다:
[{'.0': <list_iterator object at 0x7f8d37170460>, 'x': 1}]
여기서 .0은 내부 구현 세부 사항으로, 컴프리헨션 “함수”에 합성된 유일한 인자입니다.
이 PEP에서는 대신 다음을 반환합니다:
[{'lst': [1], 'x': 1}]
이제 바깥쪽 lst 변수가 지역 변수로 포함되고, 합성된 .0은 제거됩니다.
트레이스백에 컴프리헨션 프레임이 나타나지 않습니다
이 PEP에서는 컴프리헨션이 스택 트레이스에서 더 이상 자체 전용 프레임을 갖지 않습니다. 예를 들어 다음과 같은 함수가 있을 때:
def g():
raise RuntimeError("boom")
def f():
return [g() for x in [1]]
현재는 f()를 호출하면 다음과 같은 트레이스백이 발생합니다:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in f
File "<stdin>", line 5, in <listcomp>
File "<stdin>", line 2, in g
RuntimeError: boom
<listcomp>에 대한 전용 프레임에 주목하십시오.
이 PEP에서는 트레이스백이 대신 다음과 같습니다:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in f
File "<stdin>", line 2, in g
RuntimeError: boom
리스트 컴프리헨션을 위한 추가 프레임이 더 이상 존재하지 않습니다. 하지만 f 함수의 프레임에 컴프리헨션의 올바른 줄 번호가 표시되므로, 이 변경은 유용한 정보를 잃지 않으면서 트레이스백을 더 간결하게 만들 뿐입니다.
이론적으로는 stacklevel 인자와 함께 경고를 사용하는 코드가 프레임 스택 변경으로 인해 동작 변화를 겪을 수 있습니다. 그러나 실제로는 그럴 가능성이 낮아 보입니다. 그러려면 같은 라이브러리 안에서 항상 컴프리헨션을 통해 호출되는 라이브러리 코드에서 경고가 발생해야 하고, 그 경고가 컴프리헨션과 이를 포함하는 함수를 건너뛰어 라이브러리 바깥의 호출 프레임을 가리키도록 stacklevel을 3 이상으로 사용해야 합니다. 이러한 상황에서는 보통 호출하는 코드에 더 가까운 곳에서 경고를 발생시키고 더 적은 프레임을 건너뛰는 것이 더 간단하고 안정적입니다.
트레이싱/프로파일링에서 더 이상 컴프리헨션에 대한 호출/반환이 표시되지 않습니다
당연히 리스트/딕셔너리/집합 컴프리헨션이 더 이상 중첩 함수 호출로 구현되지 않으므로, sys.settrace나 sys.setprofile을 사용하는 트레이싱/프로파일링에도 더 이상 호출과 반환이 일어났다는 사실이 반영되지 않습니다.
다른 Python 구현에 미치는 영향
GraalPython 및 PyPy 측 대표자들의 의견에 따르면, 언젠가 누군가는 이 관찰 가능한 동작 변경에 의존하게 될 가능성이 높으므로 이들 구현도 그 변경에 맞춰야 할 필요를 느낄 것입니다. 따라서 다른 조건이 같다면 관찰 가능한 변경이 적을수록 작업량도 줄어듭니다. 하지만 이러한 변경은 (적어도 GraalPython의 경우) “큰 골칫거리 없이” 감당할 수 있는 수준일 것입니다.
교육 방법
컴프리헨션 문법이 중첩 함수의 생성과 호출로 이어진다거나 이어져야 한다는 점은 직관적으로 명확하지 않습니다. 이전 동작에 익숙하지 않은 새로운 사용자에게는 이 PEP의 새로운 동작이 더 직관적이고 설명도 덜 필요할 것으로 생각합니다. (“그런 함수를 정의한 적이 없는데 왜 트레이스백에 <listcomp> 줄이 있습니까? locals()에 보이는 이 .0 변수는 무엇입니까?”)
보안 영향
알려진 바 없습니다.
참조 구현
이 PEP의 참조 구현은 모든 테스트를 통과하는 CPython main 브랜치 대상 PR 형태로 제공됩니다.
참조 구현은 마이크로벤치마크 ./python -m pyperf timeit -s 'l = [1]' '[x for x in l]'를 main 브랜치보다 1.96배 빠르게 수행합니다(--enable-optimizations로 컴파일한 빌드 기준).
참조 구현은 pyperformance 벤치마크 모음의 comprehensions 벤치마크(컴프리헨션만 측정하는 마이크로벤치마크가 아니라, 실제 코드에서 가져와 컴프리헨션으로 현실적인 작업을 수행하는 코드를 테스트하는 벤치마크입니다)를 main 브랜치보다 11% 빠르게 수행합니다(역시 최적화 빌드 기준). pyperformance의 다른 벤치마크(모두 컴프리헨션을 많이 사용하지 않습니다)에서는 노이즈 범위를 벗어나는 영향이 나타나지 않습니다.
이 구현은 컴프리헨션이 아닌 코드에는 영향을 주지 않습니다.
기각된 아이디어
인라인화 없이 컴프리헨션 호출을 더 효율적으로 만들기
대안적 접근 방식은 일회용 함수 객체를 만들 필요 없이 컴프리헨션을 간소화된 방식으로 “호출”하는 새로운 옵코드를 도입하지만, 여전히 새로운 Python 프레임을 생성합니다. 이 방식은 Backwards Compatibility 절에 나열된 눈에 보이는 영향을 모두 피할 수 있으며, 성능 이점의 대략 절반을 제공합니다(마이크로벤치마크에서 1.5배 향상, pyperformance의 comprehensions 벤치마크에서 4% 향상). 또한 _PyInterpreterFrame 구조체에 새로운 포인터를 추가하고 프레임을 생성할 때마다 Py_INCREF를 새로 수행해야 하므로, (이 PEP와 달리) 모든 코드에 (매우 작은) 성능 비용이 발생합니다. 또한 향후 최적화의 여지도 더 적습니다.
이 PEP는 완전한 인라인화가 동작 변경을 정당화하고도 남을 만큼 충분한 추가 성능을 제공한다는 입장을 취합니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.