함수에서 파이썬 코드가 더 빨리 실행되는 이유는 무엇입니까?


834
def main():
    for i in xrange(10**8):
        pass
main()

이 Python 코드는 다음에서 실행됩니다 (참고 : 타이밍은 Linux의 BASH에서 시간 함수로 수행됩니다).

real    0m1.841s
user    0m1.828s
sys     0m0.012s

그러나 for 루프가 함수 내에 배치되지 않으면

for i in xrange(10**8):
    pass

훨씬 오랜 시간 동안 실행됩니다.

real    0m4.543s
user    0m4.524s
sys     0m0.012s

왜 이런거야?


16
실제로 타이밍을 어떻게 했습니까?
Andrew Jaffe

53
직관, 그것이 사실인지 확실하지 않은 경우 : 나는 그것이 범위 때문이라고 생각합니다. 함수의 경우, 새로운 스코프가 생성됩니다 (즉, 변수 이름이 값에 바인딩 된 일종의 해시). 함수가 없으면 변수가 전역 범위에 있으므로 많은 것을 찾을 수 있으므로 루프가 느려집니다.
Scharron

4
@Scharron 그것은 아닌 것 같습니다. 실행 시간에 눈에 띄게 영향을 미치지 않으면 서 범위에 200k 더미 변수를 정의했습니다.
Deestan

2
알렉스 마르 텔리는이에 관한 좋은 답변 쓴 stackoverflow.com/a/1813167/174728을
존 라 Rooy을

53
@ 샤론 당신은 반 맞습니다. 범위에 관한 것이지만 로컬에서 더 빠른 이유는 로컬 범위가 실제로 사전 대신 배열로 구현되기 때문입니다 (크기는 컴파일 타임에 알려지기 때문에).
Katriel

답변:


532

전역 변수보다 지역 변수를 저장하는 것이 더 빠른 이유를 물을 수 있습니다 . CPython 구현 세부 사항입니다.

CPython은 인터프리터가 실행하는 바이트 코드로 컴파일됩니다. 함수를 컴파일 할 때, 로컬 변수는 (고정 된 크기 어레이에 저장되어 있지dict ) 및 변수 이름은 인덱스에 할당된다. 함수에 로컬 변수를 동적으로 추가 할 수 없기 때문에 가능합니다. 그런 다음 로컬 변수를 검색하는 것은 말 그대로 목록에 대한 포인터 조회 PyObject이며 사소한 것에 대한 참조 횟수 증가입니다 .

이것을 전역 조회 ( LOAD_GLOBAL)와 대조하면 dict해시 등을 포함한 실제 검색입니다. 덧붙여서 이것이 global i전역 화되기를 원하는지 지정 해야하는 이유입니다 . 스코프 내의 변수에 할당 한 경우 컴파일러는 STORE_FAST사용자에게 알리지 않는 한 액세스를 위해 s를 발행 합니다.

그건 그렇고, 글로벌 조회는 여전히 최적화되어 있습니다. 속성 조회 foo.bar정말 느린 것입니다!

다음은 지역 변수 효율성에 대한 작은 그림 입니다.


6
이것은 PyPy, 현재 버전 (이 글을 쓰는 시점에서 1.8까지)에도 적용됩니다. OP의 테스트 코드는 함수 내부에 비해 전역 범위에서 약 4 배 느리게 실행됩니다.
GDorn

4
@Walkerneo 당신이 거꾸로 말하지 않는 한 그렇지 않습니다. katrielalex와 ecatmur의 말에 따르면, 전역 변수 조회는 저장 방법으로 인해 로컬 변수 조회보다 느립니다.
Jeremy Pridemore

2
@Walkerneo 여기서 진행되는 주요 대화는 함수 내의 로컬 변수 조회와 모듈 수준에서 정의 된 전역 변수 조회를 비교하는 것입니다. 원래의 의견에서이 답변에 대한 답장을 보았을 경우 "글로벌 변수 조회가 로컬 변수 속성 조회보다 빠르다고 생각하지 않았을 것입니다." 그들은 아닙니다. katrielalex는 로컬 변수 조회가 전역 변수보다 빠르지 만 전역 변수도 속성 조회보다 다르게 최적화되고 빠릅니다. 이 의견에 더 많은 공간이 없습니다.
Jeremy Pridemore 2016 년

3
@Walkerneo foo.bar는 로컬 액세스가 아닙니다. 객체의 속성입니다. (포맷의 부족 용서) def foo_func: x = 5, x함수에 대해 로컬이다. 액세스 x는 로컬입니다. foo = SomeClass(), foo.bar속성 액세스입니다. val = 5글로벌은 글로벌입니다. 내가 읽은 것에 따라 속도 로컬> 전역> 속성에 관해서. 그래서 접근 x에서 foo_func가장 빠른, 뒤에 val그 다음 foo.bar. foo.attr이 convo의 컨텍스트에서 로컬 조회가 함수에 속하는 변수의 조회 인 것에 대해 이야기하기 때문에 로컬 조회가 아닙니다.
Jeremy Pridemore 2012 년

3
@thedoctar는 globals()기능을 살펴 봅니다 . 그보다 더 많은 정보를 원한다면 Python의 소스 코드를 살펴 봐야 할 수도 있습니다. CPython은 일반적인 파이썬 구현의 이름 일 뿐이므로 이미 사용하고있을 것입니다!
Katriel

661

함수 내에서 바이트 코드는 다음과 같습니다.

  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (i)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        

최상위 레벨에서 바이트 코드는 다음과 같습니다.

  1           0 SETUP_LOOP              20 (to 23)
              3 LOAD_NAME                0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_NAME               1 (i)

  2          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               2 (None)
             26 RETURN_VALUE        

차이점은 STORE_FAST보다 빠릅니다 (!) STORE_NAME. 함수에서 i로컬이지만 최상위에서는 전역이기 때문입니다.

바이트 코드를 검사하려면 dis모듈을 사용하십시오 . 함수를 직접 분해 할 수 있었지만 최상위 코드를 분해하려면 compile내장 을 사용해야했습니다 .


171
실험으로 확인했습니다. 삽입 global imain기능은 실행 시간이 상당합니다.
Deestan

44
이것은 로컬 함수 변수의 경우 : 질문에 응답하지 않고 문제를 답으로 CPython 실제로 (예를 통해 사전 요청 될 때까지 (C 코드로 변경 가능한) 튜플 이러한 저장 locals()하거나, inspect.getframe()등). 상수 정수로 배열 요소를 찾는 것이 dict를 검색하는 것보다 훨씬 빠릅니다.
dmw

3
C / C ++에서도 마찬가지입니다. 전역 변수를 사용하면 상당한 속도 저하가 발생합니다.
codejammer

3
이것은 내가 처음으로 바이트 코드를 본 것입니다. 어떻게 그것을 보며 알아야 하는가?
Zack

4
@gkimsey 동의합니다. 다만이 문제는 다른 프로그래밍 언어 II) 인과 에이전트가 더 건축 측면이 아니라 진정한 의미에서 언어 자체에 기록됩니다 내가) 두 가지 공유하기를 원해요
codejammer

41

로컬 / 전역 가변 저장 시간 외에도 opcode 예측 기능으로 기능이 더 빨라집니다.

다른 답변에서 설명 하듯이 함수는 STORE_FAST루프 에서 opcode를 사용합니다 . 함수 루프의 바이트 코드는 다음과 같습니다.

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

일반적으로 프로그램이 실행될 때, 파이썬은 각 opcode를 하나씩 차례로 실행하여 스택을 추적하고 각 opcode가 실행 된 후 스택 프레임에서 다른 검사를 수행합니다. Opcode 예측은 특정 경우 Python이 다음 opcode로 직접 이동할 수 있으므로 이러한 오버 헤드를 피할 수 있음을 의미합니다.

이 경우, 파이썬이 볼 때마다 FOR_ITER(루프의 상단), STORE_FAST다음에 수행 할 opcode 를 "예측"합니다 . 그런 다음 파이썬은 다음 opcode를 들여다보고 예측이 정확하면 바로 건너 뜁니다.STORE_FAST . 이것은 두 개의 opcode를 단일 opcode로 압축하는 효과가 있습니다.

반면, STORE_NAMEopcode는 글로벌 수준의 루프에서 사용됩니다. 파이썬은 *하지 않습니다 * 이 opcode를 볼 때 비슷한 예측을 . 대신, 루프가 실행되는 속도에 명백한 영향을 미치는 평가 루프의 맨 위로 돌아 가야합니다.

이 최적화에 대한 기술적 세부 사항을 제공하기 위해 ceval.c파일 (Python 가상 머신의 "엔진") 에서 인용 한 내용은 다음과 같습니다.

일부 opcode는 쌍을 이루는 경향이 있으므로 첫 번째 코드가 실행될 때 두 번째 코드를 예측할 수 있습니다. 예를 들어 GET_ITER종종 뒤에옵니다 FOR_ITER. 그리고 FOR_ITER종종 뒤에STORE_FASTUNPACK_SEQUENCE.

예측을 검증하는 것은 상수에 대한 레지스터 변수의 단일 고속 테스트 비용이 든다. 페어링이 양호하면 프로세서 자체의 내부 분기 예측이 성공할 가능성이 높으므로 다음 opcode로 거의 오버 헤드가 거의 발생하지 않습니다. 성공적인 예측은 예측할 수없는 두 가지 분기 인 HAS_ARG테스트와 스위치 케이스를 포함하여 평가 루프를 통과하는 여행을 절약합니다 . 프로세서의 내부 분기 예측과 결합 PREDICT하면 두 개의 opcode가 마치 본문이 결합 된 단일 새 opcode 인 것처럼 실행하는 효과가 있습니다.

FOR_ITERopcode 의 소스 코드에서 정확히 예측 된 위치 를 확인할 수 있습니다 STORE_FAST.

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

PREDICT함수는 확장되어 if (*next_instr == op) goto PRED_##op즉, 예측 된 opcode의 시작으로 점프합니다. 이 경우, 우리는 여기서 점프합니다 :

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

이제 로컬 변수가 설정되고 다음 opcode가 실행됩니다. 파이썬은 반복이 끝날 때까지 계속 반복하여 매번 성공적인 예측을합니다.

파이썬 위키 페이지는 CPython과의 가상 머신이 작동하는 방법에 대한 자세한 정보가 있습니다.


사소한 업데이트 : CPython 3.6부터 예측 비용이 약간 줄었습니다. 예측할 수없는 두 개의 분기 대신 하나만 있습니다. 바이트 코드에서 워드 코드로의 전환 으로 인해 변경됩니다 . 이제 모든 "단어 코드"에는 인수가 있습니다. 명령이 논리적으로 인수를 취하지 않으면 제로화됩니다. 따라서 HAS_ARG테스트는 결코 수행되지 않으며 (일반 빌드가 수행하지 않는 컴파일 및 런타임에서 저수준 추적이 활성화 된 경우 제외) 예측할 수없는 점프는 하나만 남습니다.
ShadowRanger 2016 년

대부분 때문에 새로운 (의, CPython의 빌드에 예상치 못한 점프는 발생하지 않습니다 심지어 파이썬 3.1로 , 3.2에서 기본적으로 활성화 계산 gotos 행동); 사용하면 PREDICT매크로가 완전히 비활성화됩니다. 대신 대부분의 경우는 DISPATCH직접 분기됩니다. 그러나 분기 예측 CPU에서는 PREDICT분기 (및 예측)가 opcode마다 적용되므로 성공적인 분기 예측의 가능성이 높아 지므로 효과는와 비슷합니다 .
ShadowRanger 2016 년
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.