리스트, 딕셔너리, 집합 컴프리헨션을 정의된 위치에 직접 인라인하여 성능을 높이는 Python 제안서입니다.
라이트 / 다크 / 자동 색상 테마 전환 PEP 709 – 인라인된 컴프리헨션
저자:Carl Meyer <carl at oddbird.net>후원자:Guido van Rossum <guido at python.org>토론:Discourse thread상태:최종 유형:표준 트랙 생성일:24-Feb-2023 Python 버전:3.12 게시 이력:25-Feb-2023결의:Discourse message
목차
컴프리헨션은 현재 중첩 함수로 컴파일되며, 이는 컴프리헨션의 반복 변수에 대한 격리를 제공하지만 런타임에서는 비효율적입니다. 이 PEP는 리스트, 딕셔너리, 집합 컴프리헨션을 정의된 코드 위치에 인라인하고, 충돌하는 로컬 변수를 스택에 푸시/팝하는 방식으로 기대되는 격리를 제공할 것을 제안합니다. 이 변경은 컴프리헨션을 훨씬 더 빠르게 만듭니다. 컴프리헨션 자체만을 대상으로 한 마이크로벤치마크에서는 최대 2배까지 빨라지며, 실제 작업을 수행하는 맥락에서 컴프리헨션을 많이 사용하는 실제 코드에서 파생된 한 샘플 벤치마크에서는 11%의 속도 향상으로 이어집니다.
컴프리헨션은 Python 언어에서 인기 있고 널리 사용되는 기능입니다. 컴프리헨션을 중첩 함수로 컴파일하는 방식은 사용자 코드의 성능을 희생하고 컴파일러 단순성에 최적화되어 있습니다. 컴프리헨션 사용자 모두에게 훨씬 더 나은 런타임 성능을 제공하면서도, 의미론은 거의 동일하게 유지할 수 있으며(하위 호환성 참조), 그 대가로 필요한 것은 컴파일러 복잡도의 소폭 증가뿐입니다.
인라인은 많은 언어에서 흔히 사용되는 컴파일러 최적화입니다. 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 opcode와 30 STORE_FAST의 조합으로 달성됩니다. 전자는 컴프리헨션을 실행하기 전에 x의 바깥쪽 값을 스택에 저장하고, 후자는 컴프리헨션 실행 후 x의 바깥쪽 값(있는 경우)을 복원합니다.
컴프리헨션이 바깥 스코프의 변수에 접근하는 경우, 인라인은 이 변수들을 셀에 넣을 필요를 없애며, 그 결과 컴프리헨션(및 바깥 함수의 다른 모든 코드)은 이 변수들에 일반적인 빠른 로컬 변수처럼 접근할 수 있습니다. 이는 추가적인 성능 향상을 제공합니다.
일부 경우에는 컴프리헨션 반복 변수가 바깥 스코프에서 단순한 함수 로컬이 아니라 global, cellvar 또는 freevar일 수 있습니다. 이런 경우 컴파일러는 컴프리헨션에 들어가고 나갈 때 해당 변수의 스코프 정보도 내부적으로 푸시하고 팝하여 의미론이 유지되도록 합니다. 예를 들어, 컴프리헨션 밖에서 그 변수가 global이라면 바깥에서 참조되는 곳에서는 여전히 LOAD_GLOBAL이 사용되지만, 컴프리헨션 내부에서는 LOAD_FAST / STORE_FAST가 사용됩니다. 바깥에서 그것이 cellvar/freevar라면, 이를 저장/복원하는 데 사용되는 LOAD_FAST_AND_CLEAR / STORE_FAST는 바뀌지 않으므로(LOAD_DEREF_AND_CLEAR는 없음), 셀 안의 값만이 아니라 셀 전체가 저장/복원됩니다. 따라서 컴프리헨션은 바깥 셀에 쓰지 않습니다.
모듈 또는 클래스 스코프에서 나타나는 컴프리헨션도 인라인됩니다. 이 경우 컴프리헨션은 그렇지 않으면 LOAD_NAME / STORE_NAME만 사용될 스코프 안에서, 컴프리헨션 반복 변수에 대해서만 컴프리헨션 내부에서 LOAD_FAST / STORE_FAST 형태의 fast-locals 사용을 도입하여 격리를 유지합니다.
실질적으로 컴프리헨션은 로컬 변수가 완전히 격리된 하위 스코프를 도입하지만, 호출에 따른 성능 비용이나 스택 프레임 진입은 없습니다.
제너레이터 표현식은 현재 이 PEP의 참조 구현에서 인라인되지 않습니다. 미래에는 반환된 제너레이터 객체가 외부로 새지 않는 경우 일부 제너레이터 표현식이 인라인될 수 있습니다.
비동기 컴프리헨션은 동기 컴프리헨션과 동일하게 인라인되며, 특별한 처리는 필요하지 않습니다.
컴프리헨션 인라인은 다음과 같은 눈에 보이는 동작 변경을 일으킵니다. 구현의 이러한 변경에 맞추기 위해 표준 라이브러리나 테스트 스위트에서 필요한 수정은 없었으며, 이는 사용자 코드에 미치는 영향도 아마 최소한일 것임을 시사합니다.
문서화되지 않은 컴파일러 바이트코드 출력의 세부사항에 의존하는 특수 도구들은 물론 아래에 열거한 것 이상의 방식으로 영향을 받을 수 있지만, 이런 도구들은 어차피 Python 버전마다 바이트코드 변경에 적응해야 합니다.
컴프리헨션 내부에서 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을 사용하는 트레이싱/프로파일링에서도 더 이상 호출과 반환이 발생했다는 사실이 반영되지 않습니다.
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의 다른 벤치마크들(그중 컴프리헨션을 많이 사용하는 것은 없음)은 노이즈 범위를 벗어나는 영향이 보이지 않습니다.
이 구현은 컴프리헨션이 아닌 코드에는 영향을 주지 않습니다.
대안 접근법은 버려질 함수 객체를 만들 필요 없이 더 간소화된 방식으로 컴프리헨션을 “호출”하기 위한 새로운 opcode를 도입하지만, 여전히 새 Python 프레임은 생성합니다. 이것은 하위 호환성 아래 나열된 모든 눈에 보이는 효과를 피하고, 성능 이점의 대략 절반 정도를 제공합니다(마이크로벤치마크에서 1.5배 향상, pyperformance의 comprehensions 벤치마크에서 4% 향상). 또한 _PyInterpreterFrame 구조체에 새 포인터를 추가하고 각 프레임 생성 시 새로운 Py_INCREF를 추가해야 하므로, (이 PEP와 달리) 모든 코드에 대해 (매우 작지만) 성능 비용이 발생합니다. 또한 미래 최적화를 위한 여지도 더 적습니다.
이 PEP는 완전한 인라인이 추가적인 성능을 충분히 제공하므로, 동작 변화들을 상쇄하고도 남을 만큼 정당화된다는 입장을 취합니다.
이 문서는 퍼블릭 도메인 또는 CC0-1.0-Universal 라이선스 중 더 관대한 조건으로 제공됩니다.
출처: https://github.com/python/peps/blob/main/peps/pep-0709.rst
최종 수정: 2023-12-15 15:06:12 GMT