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

Python 개선 제안 한국어 번역

PEP 266 – 전역 변수/속성 접근 최적화

Author:
Skip Montanaro <skip at pobox.com>
Status:
Withdrawn
Type:
Standards Track
Created:
13-Aug-2001
Python-Version:
2.3
Post-History:


Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

대부분 전역 변수와 다른 모듈의 속성에 대한 바인딩은 일반적으로 Python 프로그램 실행 중에 절대 변경되지 않지만, Python의 동적 특성 때문에 그러한 전역 객체에 접근하는 코드는 객체가 필요할 때마다 전체 조회를 수행해야 합니다. 이 PEP는 대부분의 전역 객체에 접근하는 코드가 해당 객체를 지역 객체로 취급할 수 있도록 하고, 그러한 객체의 이름 바인딩을 변경하는 코드에 참조 업데이트의 부담을 지우는 메커니즘을 제안합니다.

서론

핵심 함수 sre_compile._compile을 살펴보십시오. 이 함수는 sre 모듈의 내부 컴파일 함수입니다. 이 함수는 거의 전적으로 컴파일 중인 패턴의 요소를 순회하면서 연산 코드를 알려진 상수 값과 비교하고 토큰을 출력 리스트에 추가하는 루프로 구성됩니다. 비교의 대부분은 sre_constants 모듈에서 가져온 상수와 이루어집니다. 이는 이 모듈의 컴파일된 출력에 LOAD_GLOBAL 바이트코드가 많이 포함된다는 의미입니다. 코드를 읽어 보기만 해도 작성자가 LITERAL, NOT_LITERAL, OPCODES 및 기타 여러 기호를 상수로 의도했음이 분명합니다. 그럼에도 이러한 기호가 표현식에 포함될 때마다 새로 조회해야 합니다.

대부분의 전역 접근은 실제로 “거의 상수”인 객체에 대한 접근입니다. 여기에는 현재 모듈의 전역 변수뿐 아니라 가져온 다른 모듈의 속성도 포함됩니다. 이러한 객체는 거의 변경되지 않으므로, 참조 업데이트의 부담을 이름 바인딩을 변경하는 코드에 지우는 것이 합리적으로 보입니다. sre_constants.LITERAL이 다른 객체를 참조하도록 변경된다면, sre_constants 모듈 딕셔너리를 수정하는 코드가 해당 객체에 대한 활성 참조를 모두 수정하도록 하는 것이 가치 있을 수도 있습니다. 이렇게 하면 많은 경우 전역 변수와 여러 객체의 속성을 지역 변수로 캐시할 수 있습니다. 객체에 부여된 이름과 객체 자체 사이의 바인딩이 드물게 변경된다면, 그러한 객체를 추적하는 비용은 낮고 잠재적인 효과는 상당히 클 것입니다.

이 제안의 효과를 가늠하기 위해 Python 배포판에 포함된 Pystone 벤치마크 프로그램을 수정하여 전역 함수를 캐시했습니다. 이 프로그램의 주 함수 Proc0for 루프 안에서 서로 다른 10개의 함수를 호출합니다. 또한 Func2는 루프 안에서 Func1을 반복해서 호출합니다. 함수 루프에 진입하기 전에 이러한 전역 식별자 11개의 지역 복사본을 만들면, 이 특정 벤치마크의 성능이 약 2퍼센트 향상됩니다(제 노트북에서 5561 pystones에서 5685 pystones로 향상됩니다). 이는 대부분의 전역 변수 접근을 캐시하면 성능이 향상될 것이라는 점을 어느 정도 보여 줍니다. 또한 pystone 벤치마크는 전역 모듈 속성에 사실상 전혀 접근하지 않으며, 이는 이 PEP에서 개선이 예상되는 영역입니다.

제안된 변경 사항

Python 가상 머신을 수정하여 TRACK_OBJECTUNTRACK_OBJECT 연산 코드를 포함할 것을 제안합니다. TRACK_OBJECT는 전역 이름 또는 전역 이름의 속성을 지역 변수 배열의 슬롯과 연결하고, 연결된 객체를 처음 조회하여 슬롯에 유효한 값을 채웁니다. 생성된 연결은 이름과 객체 사이의 바인딩을 변경하는 코드에 의해 기록되며, 이를 통해 연결된 지역 변수가 업데이트됩니다. UNTRACK_OBJECT 연산 코드는 이름과 지역 변수 슬롯 사이의 모든 연결을 삭제합니다.

스레드

스레드 프로그램에서 이 코드의 동작은 스레드가 없는 프로그램에서와 다르지 않습니다. 객체에 접근하기 위해 해당 객체를 잠가야 한다면, TRACK_OBJECT가 실행되기 전에 이미 객체를 잠갔어야 하며 사용을 중지한 후까지 해당 잠금을 유지해야 합니다.

FIXME: 여기에 더 작성해야 할 것 같습니다.

근거

전역 변수와 속성은 거의 변경되지 않습니다. 예를 들어, 함수가 math 모듈을 한 번 임포트하면 이름 math와 해당 이름이 참조하는 모듈 사이의 바인딩은 변경될 가능성이 낮습니다. 마찬가지로, math 모듈을 사용하는 함수가 해당 모듈의 sin 속성을 참조한다면, 그 속성도 변경될 가능성이 낮습니다. 그래도 모듈이 math.sin 함수를 호출하려 할 때마다 먼저 명령어 쌍을 실행해야 합니다:

LOAD_GLOBAL     math
LOAD_ATTR       sin

클라이언트 모듈이 항상 math.sin 을 로컬 상수로 간주하고 함수 외부의 “외부 세력”이 참조를 올바르게 유지하는 책임을 진다면, 다음과 같은 코드를 작성할 수 있습니다:

TRACK_OBJECT       math.sin
...
LOAD_FAST          math.sin
...
UNTRACK_OBJECT     math.sin

LOAD_FAST가 루프 안에 있었다면 전역 로드와 속성 조회를 줄여 얻는 이점이 상당할 수 있습니다.

이 기법은 이론적으로 모든 전역 변수 접근이나 속성 조회에 적용할 수 있습니다. 다음 코드를 살펴보십시오:

l = []
for i in range(10):
    l.append(math.sin(i))
return l

l이 로컬 변수이더라도 루프에서 l.append를 열 번 로드하는 비용은 여전히 발생합니다. 컴파일러(또는 최적화 프로그램)는 루프에서 math.sinl.append를 모두 호출하고 있음을 인식하여 추적되는 로컬 코드를 생성하도록 결정할 수 있으며, 내장 함수 range()는 루프 설정 중 한 번만 호출되므로 이 최적화를 적용하지 않을 수 있습니다. 로컬 변수 접근과 관련된 성능 문제 때문에 l.append를 추적하는 것은 math.sin과 같은 전역 변수를 추적하는 것보다 덜 매력적입니다.

Marc-Andre Lemburg가 python-dev에 올린 게시물에 따르면 [1], LOAD_GLOBAL 옵코드는 Python 가상 머신이 실행하는 전체 명령어 중 7% 이상을 차지합니다. 이는 적어도, 단순한 배열 인덱스이며 가상 머신이 추가 함수 호출을 필요로 하지 않는 LOAD_FAST 명령어에 비해 매우 비용이 큰 명령어일 수 있습니다. 저는 많은 LOAD_GLOBAL 명령어와 LOAD_GLOBAL/LOAD_ATTR 쌍을 LOAD_FAST 명령어로 변환할 수 있다고 생각합니다.

전역 변수를 많이 사용하는 코드는 전역 변수 및 속성 조회를 피하기 위해 다양한 편법에 의존하는 경우가 많습니다. 앞서 언급한 sre_compile._compile 함수는 커지는 출력 리스트의 append 메서드를 캐싱합니다. 많은 사람이 흔히 함수의 기본 인자 기능을 악용하여 전역 변수 조회를 캐싱합니다. 이 두 방식 모두 임기응변적이며, 최적화 가능한 여지를 모두 다루는 경우는 드뭅니다. (예를 들어 sre_compile._compile은 가장 자주 사용하는 두 전역, 즉 내장 len 함수와 sre_constants.py에서 임포트하는 전역 OPCODES 배열을 캐싱하지 않습니다.

질문

스레드는 어떻게 됩니까? 캐시에 있는 동안 math.sin이 변경되면 어떻게 됩니까?

저는 전역 인터프리터 잠금이 값이 손상되지 않도록 보호할 것이라고 생각합니다. 어쨌든 상황은 현재보다 나빠지지 않을 것입니다. 만약 다른 스레드가 이미 LOAD_GLOBAL math를 실행한 후, LOAD_ATTR sin을 실행하기 전에 한 스레드가 math.sin을 수정했다면, 클라이언트 스레드는 math.sin의 이전 값을 보게 될 것입니다.

그 아이디어는 이렇습니다. 아래에서는 다중 속성 로드를 예시로 사용하는데, 이는 자주 일어나는 일이기 때문이 아니라 추가 호출로 재귀적 특성을 보여줌으로써 제가 염두에 두고 있는 것이 더 명확해지기를 바라기 때문입니다. 모듈 foo에 정의된 함수가 spam.eggs.ham에 접근하고자 하며, spamfoo의 모듈 수준에서 임포트된 모듈이라고 가정해 봅시다.:

import spam
...
def somefunc():
...
x = spam.eggs.ham

somefunc에 진입하면 TRACK_GLOBAL 명령이 실행됩니다.:

TRACK_GLOBAL spam.eggs.ham n

spam.eggs.ham은 함수의 상수 배열에 저장된 문자열 리터럴입니다. n은 fastlocals 인덱스입니다. &fastlocals[n]은 실행 중인 프레임의 fastlocals 배열에서 슬롯 n을 가리키는 참조이며, 이곳이 spam.eggs.ham 참조가 저장될 위치입니다. 제가 구상하는 동작은 다음과 같습니다:

  1. TRACK_GLOBAL 명령은 이름 spam이 가리키는 객체를 찾아 그 모듈 스코프에서 발견합니다. 그런 다음 다음과 같은 C 함수를 실행합니다:
    _PyObject_TrackName(m, "spam.eggs.ham", &fastlocals[n])
    

    여기서 m은 속성 spam을 가진 모듈 객체입니다.

  2. 모듈 객체는 앞의 spam.을 제거하고, 이름 eggs에 대한 바인딩이 바뀔 경우를 대비해 필요한 정보(eggs.ham&fastlocals[n])를 저장합니다. 그런 다음 자신의 딕셔너리에서 키 eggs가 가리키는 객체를 찾아 재귀적으로 다음을 호출합니다:
    _PyObject_TrackName(eggs, "eggs.ham", &fastlocals[n])
    
  3. eggs 객체는 앞의 eggs.를 제거하고, (ham, &fastlocals[n]) 정보를 저장한 후, 자신의 네임스페이스에서 ham이라는 이름의 객체를 찾아 _PyObject_TrackName을 다시 한 번 호출합니다:
    _PyObject_TrackName(ham, "ham", &fastlocals[n])
    
  4. ham 객체는 앞의 문자열을 제거하고(이번에는 “.”이 없지만, 이는 사소한 부분입니다), 결과가 비어 있음을 확인한 다음, 자신의 값(아마도 self)을 사용하여 전달받은 위치를 갱신합니다:
    Py_XDECREF(&fastlocals[n]);
    &fastlocals[n] = self;
    Py_INCREF(&fastlocals[n]);
    

    이 시점에서 spam.eggs.ham을 해석하는 데 관여한 각 객체는 자신의 네임스페이스에서 어떤 항목을 추적해야 하는지, 그리고 해당 이름이 변경될 경우 어떤 위치를 갱신해야 하는지 알고 있습니다. 더 나아가, 자신이 로컬 저장소에서 추적 중인 이름 하나가 변경되면, 변경이 이루어진 후 새 객체를 사용하여 _PyObject_TrackName을 호출할 수 있습니다. 이 연쇄의 맨 끝에서는, 마지막 객체가 항상 이름을 제거한 후 결과가 빈 문자열임을 확인하고, 자신의 값을 전달받은 위치에 채워 넣어야 함을 알게 됩니다.

    점 표기식 spam.eggs.ham이 가리키는 객체가 스코프를 벗어나려 할 때, UNTRACK_GLOBAL spam.eggs.ham n 명령이 실행됩니다. 이는 TRACK_GLOBAL이 설정한 모든 추적 정보를 삭제하는 효과를 냅니다.

    추적 연산이 비용이 많이 드는 것처럼 보일 수 있지만, 추적되는 객체는 “거의 상수”에 가깝다고 가정되므로, 설정 비용은 바라건대 전역 로드 대신 여러 번의 지역 로드와 상쇄될 것임을 상기하십시오. 속성이 있는 전역 변수의 경우 추적 설정 비용이 커지지만, 추가적인 LOAD_ATTR비용을 피함으로써 상쇄됩니다. TRACK_GLOBAL 명령어는 최상위 객체가 어디에 위치하는지 판단하기 위해 체인의 첫 번째 이름에 대해 PyDict_GetItemString을 수행해야 합니다. 체인 안의 각 객체는 문자열과 주소를 어딘가에, 아마도 저장 위치(예: &fastlocals[n])를 키로, 문자열을 값으로 사용하는 딕셔너리에 저장해야 합니다. (이 딕셔너리는 객체별 딕셔너리 대신, 키가 객체 주소인 딕셔너리들의 중앙 딕셔너리일 수도 있습니다.) 그 반대여서는 안 되는데, 여러 활성 프레임이 spam.eggs.ham을 추적하고 싶어 할 수 있지만, 그 이름을 자신의 fast locals 슬롯 중 하나와 연관시키고 싶어 하는 프레임은 오직 하나뿐이기 때문입니다.

미해결 문제

스레딩

이 (멍청한) 코드는 어떨까요?:

l = []
lock = threading.Lock()
...
def fill_l()::
   for i in range(1000)::
      lock.acquire()
      l.append(math.sin(i))
      lock.release()
...
def consume_l()::
   while 1::
      lock.acquire()
      if l::
         elt = l.pop()
      lock.release()
      fiddle(elt)

코드를 정적으로 분석하는 것만으로는 잠금이 무엇을 보호하는지 명확하지 않습니다. (컴파일 시점에는 스레드가 관여하는지조차 알 수 없지 않습니까?) 이것이 fill_l 함수에서 l.appendmath.sin을 추적하려는 시도에 영향을 미치게 될까요, 또는 영향을 미쳐야 할까요?

가상의 track_objectuntrack_object 내장 함수로 코드에 주석을 단다면(이것을 제안하는 것이 아니라, 그저 어디에 무엇이 들어갈지 보여주는 것뿐입니다!), 다음과 같이 됩니다:

l = []
lock = threading.Lock()
...
def fill_l()::
   track_object("l.append", append)
   track_object("math.sin", sin)
   for i in range(1000)::
      lock.acquire()
      append(sin(i))
      lock.release()
   untrack_object("math.sin", sin)
   untrack_object("l.append", append)
...
def consume_l()::
   while 1::
      lock.acquire()
      if l::
         elt = l.pop()
      lock.release()
      fiddle(elt)

이것은 스레드가 있을 때와 없을 때 모두 올바른가요 (또는 적어도 스레드가 있을 때와 없을 때 동일하게 틀린가요)?

중첩 스코프

중첩 스코프의 존재는 TRACK_GLOBAL이 전역 변수를 찾는 위치에 영향을 미치겠지만, 그 이후의 것에는 영향을 미치지 않아야 합니다. (제 생각에는 그렇습니다.)

누락된 속성

spam.eggs.ham이 가리키는 객체를 추적하고 있는데, spam.eggsham 속성이 없는 객체로 재바인딩된다고 가정해 봅시다. 프로그래머가 현재의 파이썬 가상 머신에서 spam.eggs.ham을 해석하려고 시도할 경우 이것이 AttributeError가 되리라는 것은 명확하지만, 프로그래머가 이 경우를 미리 예상했다고 가정해 봅시다:

if hasattr(spam.eggs, "ham"):
    print spam.eggs.ham
elif hasattr(spam.eggs, "bacon"):
    print spam.eggs.bacon
else:
    print "what? no meat?"

추적 정보가 재계산될 때 AttributeError를 발생시킬 수는 없습니다. AttributeError를 발생시키지 않고 대신 추적 상태를 그대로 두면, 프로그래머가 매우 미묘한 오류에 빠지게 될 수 있습니다.

이 문제에 대한 한 가지 해결책은 함수가 직접 참조하는 각 점 표기 표현식의 가능한 최단 루트를 추적하는 것입니다. 위 예제에서 spam.eggs는 추적되겠지만, spam.eggs.hamspam.eggs.bacon은 추적되지 않을 것입니다.

누가 궂은일을 합니까?

질문 절에서 저는 _PyObject_TrackName 함수의 존재를 가정했습니다. API를 명세하는 것은 상당히 쉽지만, 이면의 구현은 그리 명확하지 않습니다. 중앙 딕셔너리를 사용해 이름/위치 매핑을 추적할 수 있겠지만, 이 새로운 기능을 수용하려면 모든 setattr 함수를 수정해야 할 것으로 보입니다.

모든 타입이 속성을 설정할 때 PyObject_GenericSetAttr 함수를 사용한다면 갱신 코드를 어느 정도 국소화할 수 있을 것입니다. 하지만 그렇지 않으므로(그리 놀라운 일은 아닙니다만), 모든 getattrfuncgetattrofunc 함수를 갱신해야 할 것으로 보입니다. 또한 이렇게 되면 C 확장 모듈 작성자에게 속성 값이 변경될 때 어떤 함수(PyObject_TrackUpdate?)를 호출하도록 절대적으로 요구하게 될 것입니다.

마지막으로, 일부 속성은 어떤 종류의 setattr 메서드에 대한 직접 호출이 아니라 부수 효과로 설정될 가능성이 상당히 있습니다. 장치 레지스터가 변경될 때마다 그 내용을 객체의 struct 내 슬롯으로 복사하는 인터럽트 루틴을 가진 장치 인터페이스 모듈을 생각해 보십시오. 이러한 상황에서는 모듈 작성자가 더 광범위한 수정을 해야 할 것입니다. 이러한 상황을 컴파일 시점에 식별하는 것은 불가능할 것입니다. 객체의 코드가 전역 추적에 안전한지를 나타내기 위해 PyTypeObjects에 추가 슬롯을 넣을 수 있을 것으로 생각합니다. 기본값은 0(Py_TRACKING_NOT_SAFE)이 될 것입니다. 확장 모듈 작성자가 필요한 추적 지원을 구현했다면, 그 필드는 1(Py_TRACKING_SAFE)로 초기화될 수 있습니다. _PyObject_TrackName은 그 필드를 확인하여, 작성자가 추적에 안전하다고 명시적으로 밝히지 않은 객체를 추적하도록 요청받으면 경고를 낼 수 있습니다.

논의

Jeremy Hylton은 대안이 되는 제안을 내놓았습니다 [2]. 그의 제안은 전역 이름 조회에 사용할 딕셔너리/리스트 하이브리드 객체를 만들어, 전역 변수 접근이 지역 변수 접근과 더 비슷하게 보이도록 하려는 것입니다. 검토할 수 있는 C 코드는 없지만, 그의 제안에 제시된 파이썬 구현은 여전히 딕셔너리 키 조회를 필요로 하는 것으로 보입니다. 그의 제안이 지역 변수 속성 조회를 빠르게 할 수 있을 것으로는 보이지 않는데, 잠재적인 성능 부담을 해결할 수 있다면 일부 상황에서는 그렇게 하는 것이 가치가 있을 수도 있습니다.

하위 호환성

하위 호환성에 심각한 문제가 있으리라고는 생각하지 않습니다. 당연히, TRACK_OBJECT 옵코드를 포함한 파이썬 바이트코드는 이전 버전의 인터프리터에서는 실행될 수 없지만, 버전 간 바이트코드 수준의 호환성 깨짐은 흔히 있는 일로 간주됩니다.

구현

미정입니다. 바로 이 부분에서 도움이 필요합니다. 중앙 이름/위치 레지스트리가 있어야 하거나 객체 속성을 수정하는 코드가 수정되어야 한다고 생각하지만, 이를 진행하는 가장 좋은 방법은 확신할 수 없습니다. STORE_GLOBALSTORE_ATTR 옵코드를 구현하는 코드를 보면, PyDict_SetItemPyObject_SetAttr 또는 이들의 String 변형에 몇 가지 변경이 필요할 것으로 보입니다. 이상적으로는, 이러한 변경을 국지화할 수 있는 상당히 중앙화된 장소가 있어야 합니다. 지역 변수의 속성을 추적하는 것을 고려하기 시작하면 STORE_FAST도 수정해야 하는 문제에 부딪히게 되는데, 이는 문제가 될 수 있습니다. 지역 변수의 이름 바인딩은 훨씬 더 자주 변경되기 때문입니다. (변수의 이름 바인딩이 변경되는 지역 변수에 대해서는 옵티마이저가 속성 추적 코드 삽입을 피할 수 있으리라 생각합니다.)

성능

(현재 이를 증명할 코드는 없지만) TRACK_OBJECT를 구현하는 것이 일반적으로 단일 LOAD_GLOBAL 명령이나 LOAD_GLOBAL/LOAD_ATTR 쌍보다 훨씬 더 비용이 들지는 않으리라 생각합니다. 옵티마이저는 객체 접근이 루프 내에서 발생하지 않는 한 LOAD_GLOBALLOAD_GLOBAL/LOAD_ATTR를 새 방식으로 변환하는 것을 피할 수 있어야 합니다. 더 나아가서는, 현재의 Python 가상 머신을 대체하는 레지스터 지향 방식 [3]이 대부분의 LOAD_FAST 명령도 없앨 수 있을 것으로 생각됩니다.

추적되는 객체의 수는 비교적 적어야 합니다. 모든 활성 스레드의 모든 활성 프레임이 객체를 추적하고 있을 수 있지만, 이는 주어진 애플리케이션에서 정의된 함수의 수에 비하면 적어 보입니다.

참고 자료