Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

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

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 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 opcode와, 컴프리헨션을 실행하기 전에 외부 x의 값이 있으면 이를 스택에 저장하는 기능, 그리고 컴프리헨션을 실행한 후 외부 x의 값이 있으면 이를 복원하는 30 STORE_FAST의 조합으로 달성됩니다.

컴프리헨션이 외부 스코프의 변수에 액세스하는 경우, 인라이닝을 사용하면 이러한 변수를 셀에 배치할 필요가 없어져 컴프리헨션과 외부 함수의 다른 모든 코드가 대신 해당 변수에 일반적인 빠른 로컬 변수로 액세스할 수 있습니다. 이를 통해 추가적인 성능 향상을 얻을 수 있습니다.

일부 경우 외부 스코프에서 컴프리헨션의 반복 변수는 단순한 함수 로컬 변수가 아니라 전역 변수, 셀 변수 또는 자유 변수일 수 있습니다. 이러한 경우 컴파일러는 의미론을 유지하기 위해 컴프리헨션에 진입하거나 컴프리헨션을 빠져나올 때 변수의 스코프 정보도 내부적으로 푸시하고 팝합니다. 예를 들어 컴프리헨션 외부에서 변수가 전역 변수인 경우, 외부에서 참조될 때는 계속 LOAD_GLOBAL이 사용되지만 컴프리헨션 내부에서는 LOAD_FAST / STORE_FAST가 사용됩니다. 컴프리헨션 외부에서 변수가 셀 변수/자유 변수인 경우, 이를 저장하고 복원하는 데 사용되는 LOAD_FAST_AND_CLEAR / STORE_FAST는 변경되지 않습니다(LOAD_DEREF_AND_CLEAR는 없음). 이는 전체 셀(셀 안의 값만이 아님)이 저장되고 복원된다는 의미이므로 컴프리헨션이 외부 셀에 쓰지 않습니다.

모듈 또는 클래스 스코프에서 발생하는 컴프리헨션도 인라인됩니다. 이 경우 컴프리헨션은 격리를 유지하면서, 그렇지 않다면 LOAD_NAME / STORE_NAME만 사용될 스코프에서 컴프리헨션 내부의 컴프리헨션 반복 변수에 대해서만 빠른 로컬 변수(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 구현에 미치는 영향

GraalPythonPyPy 대표들의 의견에 따르면, 언젠가 누군가가 그것들에 의존할 가능성이 높으므로, 그들은 여기서 발생하는 관찰 가능한 동작 변경 사항에 적응해야 할 필요를 느낄 가능성이 높습니다. 따라서 다른 조건이 같다면 관찰 가능한 변경 사항이 적을수록 작업량도 줄어듭니다. 그러나 이러한 변경 사항은 (적어도 GraalPython의 경우) “큰 골칫거리 없이” 관리할 수 있을 것입니다.

이를 가르치는 방법

컴프리헨션 구문이 중첩 함수의 생성 및 호출로 이어진다는 것이 직관적으로 명확하지는 않습니다. 이전 동작에 이미 익숙하지 않은 신규 사용자에게는 이 PEP의 새로운 동작이 더 직관적이고 설명도 덜 필요할 것으로 생각합니다. (“그런 함수를 정의하지도 않았는데 트레이스백에 왜 <listcomp> 줄이 있습니까? locals()에서 보이는 이 .0 변수는 무엇입니까?”)

보안 영향

알려진 사항은 없습니다.

참조 구현

이 PEP에는 모든 테스트를 통과하는 CPython main 브랜치에 대한 PR 형태의 참조 구현이 있습니다.

참조 구현은 --enable-optimizations로 컴파일된 빌드에서 마이크로 벤치마크 ./python -m pyperf timeit -s 'l = [1]' '[x for x in l]'main 브랜치보다 1.96배 빠르게 수행합니다.

참조 구현은 pyperformance 벤치마크 제품군의 comprehensions 벤치마크(컴프리헨션만을 대상으로 하는 마이크로 벤치마크가 아니라, 실제 세계에서 파생된 코드가 컴프리헨션을 사용해 현실적인 작업을 수행하는지를 테스트함)를 main 브랜치보다 11% 빠르게 수행합니다(다시 말해 최적화된 빌드에서 그렇습니다). pyperformance의 다른 벤치마크(컴프리헨션을 많이 사용하지 않는 벤치마크)는 노이즈 범위를 벗어나는 영향을 보이지 않습니다.

이 구현은 컴프리헨션이 아닌 코드에 영향을 주지 않습니다.

거부된 아이디어

인라이닝 없는 더 효율적인 컴프리헨션 호출

대체 접근법은 버릴 함수 객체를 생성할 필요 없이 간소화된 방식으로 컴프리헨션을 “호출”하기 위한 새로운 오피코드를 도입하지만, 여전히 새로운 Python 프레임을 생성합니다. 이는 Backwards Compatibility 아래에 나열된 모든 가시적 영향을 피하고, 성능 향상의 대략 절반(마이크로 벤치마크에서 1.5배 향상, pyperformance의 comprehensions 벤치마크에서 4% 향상)을 제공합니다. 또한 _PyInterpreterFrame 구조체에 새 포인터를 추가하고 각 프레임 생성 시 새로운 Py_INCREF를 수행해야 하므로, 이 PEP와 달리 모든 코드에 대해 아주 작은 성능 비용이 발생합니다. 또한 향후 최적화를 위한 여지도 더 적습니다.

이 PEP는 완전한 인라이닝이 동작 변경을 정당화하고도 남을 만큼 충분한 추가 성능을 제공한다고 봅니다.