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 모듈 dict를 수정하는 코드가 그 객체에 대한 활성 참조를 모두 바로잡게 하는 것이 가치 있을 수 있습니다. 그렇게 하면 많은 경우 전역 변수와 여러 객체의 속성을 지역 변수로 캐시할 수 있습니다. 객체에 부여된 이름과 객체 자체 사이의 바인딩이 드물게 바뀐다면, 이러한 객체를 추적하는 비용은 낮고 잠재적인 이득은 상당히 클 것입니다.

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

제안하는 변경 사항

저는 Python 가상 머신을 수정하여 TRACK_OBJECT와 UNTRACK_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.sin과 l.append가 모두 루프 안에서 호출된다는 것을 인식하고 추적되는 로컬 코드를 생성하기로 결정할 수 있습니다. 반면 내장 range() 함수는 루프를 준비할 때 한 번만 호출되므로 이런 코드를 생성하지 않습니다. 지역 변수 접근과 관련된 성능 문제 때문에 l.append를 추적하는 것은 math.sin 같은 전역 이름을 추적하는 것보다 덜 매력적입니다.

Marc-Andre Lemburg가 python-dev에 올린 글 [1]에 따르면, LOAD_GLOBAL 옵코드는 파이썬 가상 머신이 실행하는 전체 명령어의 7% 이상을 차지합니다. 이 명령어는 적어도 LOAD_FAST 명령어에 비하면 매우 비쌀 수 있습니다. 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에 접근하려 하고, spam이 foo의 모듈 수준에서 임포트된 모듈이라고 가정합시다:

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.append나 math.sin을 추적하려는 시도에 영향을 주게 됩니까, 혹은 영향을 주어야 합니까?

가상의 track_object와 untrack_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.eggs가 ham 속성이 없는 객체에 다시 바인딩된다고 가정해 봅시다. 현재의 Python 가상 머신에서 프로그래머가 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.ham과 spam.eggs.bacon은 추적되지 않습니다.

궂은일은 누가 맡습니까?

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

모든 타입이 속성을 설정할 때 PyObject_GenericSetAttr 함수를 사용한다면 갱신 코드를 어느 정도 한곳에 모을 수 있을 것입니다. 그러나 실제로는 그렇지 않으므로(그리 놀라운 일은 아닙니다), 모든 getattrfunc 및 getattrofunc 함수를 갱신해야 할 것으로 보입니다. 또한 이렇게 되면 C 확장 모듈 작성자는 속성 값이 바뀔 때 어떤 함수(PyObject_TrackUpdate?)를 반드시 호출해야 한다는 절대적인 요구 사항을 지게 됩니다.

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

논의

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

하위 호환성

하위 호환성과 관련된 심각한 문제는 없을 것이라고 생각합니다. 물론 TRACK_OBJECT 옵코드를 포함한 Python 바이트코드는 이전 버전의 인터프리터에서 실행할 수 없습니다. 하지만 바이트코드 수준의 호환성은 버전 사이에 깨지는 것이 일반적인 전제입니다.

구현

미정. 바로 이 부분에서 도움이 필요합니다. 중앙 이름/위치 레지스트리가 있어야 하거나, 아니면 객체 속성을 수정하는 코드를 수정해야 한다고 생각하지만, 이를 진행하는 가장 좋은 방법이 무엇인지는 확신하지 못합니다. STORE_GLOBAL과 STORE_ATTR 옵코드를 구현하는 코드를 살펴보면 PyDict_SetItem과 PyObject_SetAttr, 또는 그 문자열(String) 변형에 일부 변경이 필요할 것으로 보입니다. 이상적으로는 이러한 변경을 한곳에 모을 수 있는 상당히 중심적인 위치가 있으면 좋을 것입니다. 지역 변수의 속성 추적까지 고려하기 시작하면 STORE_FAST를 수정하는 문제까지 다루게 되는데, 지역 변수의 이름 바인딩은 훨씬 더 자주 바뀌므로 이는 문제가 될 수 있습니다. (변수의 이름 바인딩이 바뀌는 지역 변수에 대해서는 최적화기가 속성 추적 코드를 삽입하지 않을 수 있다고 생각합니다.)

성능

(현재로서는 이를 증명할 코드가 없지만) TRACK_OBJECT를 구현하는 비용은 일반적으로 LOAD_GLOBAL 명령어 하나나 LOAD_GLOBAL/LOAD_ATTR 쌍보다 크게 비싸지 않을 것이라고 생각합니다. 최적화기는 객체 접근이 루프 안에서 일어나지 않는 한 LOAD_GLOBAL과 LOAD_GLOBAL/LOAD_ATTR를 새로운 방식으로 변환하지 않을 수 있어야 합니다. 더 나아가 현재 Python 가상 머신을 대체하는 레지스터 기반 가상 머신 [3]이 나온다면 LOAD_FAST 명령어 대부분도 없앨 수 있을 것입니다.

추적되는 객체의 수는 비교적 적을 것입니다. 모든 활성 스레드의 모든 활성 프레임이 객체를 추적하고 있을 가능성도 있지만, 이는 특정 애플리케이션에 정의된 함수의 수에 비하면 적은 편으로 보입니다.

참고 문헌