파이썬 내장 함수는 왜 빠를까

이태성·2022년 5월 17일

계기

파이썬의 내장 함수가 빠른 이유가 궁금했다.
알고리즘을 공부할때 이런저런 이유로 빠르다고 말만 들었지 왜 빠른지에 대해선 크게 관심이 없었다.

그러나 누군가에게 코드리뷰를 할때 이것에 대해서 이유를 묻는다면?
딱히 논리적으로 설명해줄 방법이 떠오르지 않아 이 글을 쓰게 되었다.





실험

함수 네이밍이 구린건 아직 실력이 부족한 부분이라 양해바랍니다....

간단하게 덧셈하는 상황을 가정하였다.
파이썬의 반복문인 for과 while로 시간을 비교하였다.

NUM = 100_000

def for_plus():
    x = 0
    for i in range(NUM):
        x += 1

    return x


def while_plus():
    x = 0
    while x < NUM:
        x += 1

    return x


시간은 크게 차이가 나지 않았다.
다만 단순히 시간을 측정하기 위한 것은 아니기때문에 cpython 기반의 byte 코드를 봐서 비교해보려 한다.

import timeit

print('for_plus: ', timeit.timeit(for_plus, number=1))
print('while_plus: ', timeit.timeit(while_plus, number=1))

for_plus:    0.011830508999999989
while_plus:  0.013393183000000030


비슷한 양상으로 보이지만 아마 COMPARE_OP 이부분에서 while문이 조금 더 걸린것이 아닌가 하는것으로 추정된다.

결국 for문은 정해진 만큼만 반복하며 더하면 되는것이지만
while문은 한번 반복할때마다 조건을 확인해야되는 것이 약간의 차이를 만든게 아닐까 싶다.

import dis

print(dis.dis(for_plus))

  9           0 LOAD_CONST               1 (0)
              2 STORE_FAST               0 (x)

 10           4 LOAD_GLOBAL              0 (range)
              6 LOAD_GLOBAL              1 (NUM)
              8 CALL_FUNCTION            1
             10 GET_ITER
        >>   12 FOR_ITER                12 (to 26)
             14 STORE_FAST               1 (i)

 11          16 LOAD_FAST                0 (x)
             18 LOAD_CONST               2 (1)
             20 INPLACE_ADD
             22 STORE_FAST               0 (x)
             24 JUMP_ABSOLUTE           12

 13     >>   26 LOAD_FAST                0 (x)
             28 RETURN_VALUE
import dis

print(dis.dis(while_plus))

 21           0 LOAD_CONST               1 (0)
              2 STORE_FAST               0 (x)

 22     >>    4 LOAD_FAST                0 (x)
              6 LOAD_GLOBAL              0 (NUM)
              8 COMPARE_OP               0 (<)
             10 POP_JUMP_IF_FALSE       22

 23          12 LOAD_FAST                0 (x)
             14 LOAD_CONST               2 (1)
             16 INPLACE_ADD
             18 STORE_FAST               0 (x)
             20 JUMP_ABSOLUTE            4

 25     >>   22 LOAD_FAST                0 (x)
             24 RETURN_VALUE




다음은 for문과 list comprehension으로 반복하여 더하기로 한다.
완전 동일한 상황은 아니지만 보통 for문보다 list comprehension이 빠르다고 알려져있는데 얼마나 빠른지 비교해본다.

NUM = 100_000

def for_plus():
    x = 0
    for i in range(NUM):
        x += 1

    return x
    
def list_comprehension():
    return sum([i for i in range(NUM)])


뭔가 예상보다? 차이가 없다.
for vs while 만큼의 차이가 있기때문에 이정도면 유의미하다고 볼 수 있을까란 생각이든다.
byte code 호출을 봐야겠다.

import timeit

print('for_plus: ', timeit.timeit(for_plus, number=1))
print('list_comprehension: ', timeit.timeit(list_comprehension, number=1))
    
for_plus:            0.011626638999999994
list_comprehension:  0.008869958000000011


이전 for vs while 과 전혀 다른 다른 코드들이 보인다.

차이점은 list comprehension이 MAKE_FUNCTION으로 별도의 함수를 만들어서 처리한다는 점과
별도의 함수에서 LIST_APPEND를 호출한다는 점이다.
아무래도 리스트에 담고 그것을 더하는 것이기 때문에 호출되는것으로 보인다.

import dis

print(dis.dis(for_plus))

  9           0 LOAD_CONST               1 (0)
              2 STORE_FAST               0 (x)

 10           4 LOAD_GLOBAL              0 (range)
              6 LOAD_GLOBAL              1 (NUM)
              8 CALL_FUNCTION            1
             10 GET_ITER
        >>   12 FOR_ITER                12 (to 26)
             14 STORE_FAST               1 (i)

 11          16 LOAD_FAST                0 (x)
             18 LOAD_CONST               2 (1)
             20 INPLACE_ADD
             22 STORE_FAST               0 (x)
             24 JUMP_ABSOLUTE           12

 13     >>   26 LOAD_FAST                0 (x)
             28 RETURN_VALUE
import dis

print(dis.dis(list_comprehension))

 17           0 LOAD_GLOBAL              0 (sum)
              2 LOAD_CONST               1 (<code object <listcomp> at 0x10ce5a7c0, file "/", line 17>)
              4 LOAD_CONST               2 ('list_comprehension.<locals>.<listcomp>')
              6 MAKE_FUNCTION            0
              8 LOAD_GLOBAL              1 (range)
             10 LOAD_GLOBAL              2 (NUM)
             12 CALL_FUNCTION            1
             14 GET_ITER
             16 CALL_FUNCTION            1
             18 CALL_FUNCTION            1
             20 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x10ce5a7c0, file "/", line 17>:
 17           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                 8 (to 14)
              6 STORE_FAST               1 (i)
              8 LOAD_FAST                1 (i)
             10 LIST_APPEND              2
             12 JUMP_ABSOLUTE            4
        >>   14 RETURN_VALUE




하지만 이렇게 놓고보니 불공평한 비교였던것같다.
list_comprehension함수는 단순히 더하는 행위 외에 추가로 배열에 담고 있으니 말이다.

그래서 이렇게 다시 비교를 해봤다.

NUM = 100_000

def list_comprehension1():
    return sum([i for i in range(NUM)])
    
def list_comprehension2():
    return sum(range(NUM))


이렇게 비교하니 약 4배의 차이가 났다.
확실히 리스트에 데이터를 담는다는 행위가 비용이 드는 행위이다.

import timeit

print('list_comprehension1: ', timeit.timeit(list_comprehension1, number=1))
print('list_comprehension2: ', timeit.timeit(list_comprehension2, number=1))

list_comprehension1:  0.008895217000000010
list_comprehension2:  0.002311021000000024

이전값과 비교하면 좀 압도적으로 차이가 난다.
물론 이것도 + 연산이냐 sum 함수를 사용했냐의 차이도 존재하지만 그럼에도 약 5배의 차이는 엄청나다.

for_plus:             0.011760136000000032
list_comprehension2:  0.002311021000000024


byte code를 보니 호출 횟수 자체가 압도적으로 적다.
내장함수들은 최적화를 한 커스텀이 들어가다 보니 이런식의 결과가 나온것으로 보인다.

import dis

print(dis.dis(list_comprehension1))

 17           0 LOAD_GLOBAL              0 (sum)
              2 LOAD_CONST               1 (<code object <listcomp> at 0x10ce5a7c0, file "/", line 17>)
              4 LOAD_CONST               2 ('list_comprehension1.<locals>.<listcomp>')
              6 MAKE_FUNCTION            0
              8 LOAD_GLOBAL              1 (range)
             10 LOAD_GLOBAL              2 (NUM)
             12 CALL_FUNCTION            1
             14 GET_ITER
             16 CALL_FUNCTION            1
             18 CALL_FUNCTION            1
             20 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x10ce5a7c0, file "/", line 17>:
 17           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                 8 (to 14)
              6 STORE_FAST               1 (i)
              8 LOAD_FAST                1 (i)
             10 LIST_APPEND              2
             12 JUMP_ABSOLUTE            4
        >>   14 RETURN_VALUE
import dis

print(dis.dis(list_comprehension2))

 29           0 LOAD_GLOBAL              0 (sum)
              2 LOAD_GLOBAL              1 (range)
              4 LOAD_GLOBAL              2 (NUM)
              6 CALL_FUNCTION            1
              8 CALL_FUNCTION            1
             10 RETURN_VALUE

아까 리스트에 데이터를 담고 더하는 행위중 데이터를 담는 행위의 비용이 높은것으로 보였는데 이번엔 담는 행위만 비교해보기로 한다.





for문과 list comprehension으로 데이터를 리스트에 담을때 비교이다.

NUM = 100_000

def for_append():
    x = []
    for i in range(NUM):
        x.append(i)

    return x


def list_comprehension_append():
    x = [i for i in range(NUM)]
    return x


약 2배의 차이가 존재했다.
아마 여기에 더하는 행위까지 했으면 더 큰 차이가 존재했을것이다.

import timeit

print('for_append: ', timeit.timeit(for_append, number=1))
print('list_comprehension_append: ', timeit.timeit(list_comprehension_append, number=1))
    
for_append:                 0.017027729999999963
list_comprehension_append:  0.007882961999999993


2배의 시간 차이가 존재하는 이유를 분석해보면 아마도 CALL_FUNCTIONLIST_APPEND의 차이로 추정된다.

네이밍에 따른 예측을 해보면 아마도 append와 같은 메소드를 콜하면 CALL_FUNCTION과 같이 공통?으로 사용되는 코드가 사용되는것 같으며
list comprehension은 LIST_APPEND이라는 최적화된 코드가 실행되는것으로 생각된다.

import dis

print(dis.dis(for_append))

 33           0 BUILD_LIST               0
              2 STORE_FAST               0 (x)

 34           4 LOAD_GLOBAL              0 (range)
              6 LOAD_GLOBAL              1 (NUM)
              8 CALL_FUNCTION            1
             10 GET_ITER
        >>   12 FOR_ITER                14 (to 28)
             14 STORE_FAST               1 (i)

 35          16 LOAD_FAST                0 (x)
             18 LOAD_METHOD              2 (append)
             20 LOAD_FAST                1 (i)
             22 CALL_METHOD              1
             24 POP_TOP
             26 JUMP_ABSOLUTE           12

 37     >>   28 LOAD_FAST                0 (x)
             30 RETURN_VALUE
import dis

print(dis.dis(list_comprehension_append))

 41           0 LOAD_CONST               1 (<code object <listcomp> at 0x1104aeb30, file "/", line 41>)
              2 LOAD_CONST               2 ('list_comprehension_append.<locals>.<listcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_GLOBAL              1 (NUM)
             10 CALL_FUNCTION            1
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_FAST               0 (x)

 42          18 LOAD_FAST                0 (x)
             20 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x1104aeb30, file "/", line 41>:
 41           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                 8 (to 14)
              6 STORE_FAST               1 (i)
              8 LOAD_FAST                1 (i)
             10 LIST_APPEND              2
             12 JUMP_ABSOLUTE            4
        >>   14 RETURN_VALUE




정리

  1. 동일 상황에서 for와 while은 생각보다 차이가 나지 않는다. 다만 반복만 같지 for와 while의 사용처가 다르니 구분해서 사용할것

  2. list comprehension을 사용할 수 있는 상황이면 for문 보다 더 애용해서 사용하자! 물론 가독성을 고려해서 판단해야한다.

  3. 내장함수에서 제공되는 기능이라면 코드로 만들지 말고 적극적으로 내장함수를 사용하자!



마치며

이렇게 글로 정리해놓으니 누군가 물어볼때 대략적이나마 뭐라고 말해줘야 될지 알것같다.

파이썬으로 밥먹고 사는데 누가 이거 왜 빠른가요?라고 했을때 바보같이 그냥 빨라요! 하면 뭔가 좀 그렇다....

물론 깔끔한 글도 아니고 뭔가 겉핡기식으로 파악해본것 같지만 이렇게 해보다보면 유의만 결과를 도출해낼 수 있지 않을까라는 막연한 생각이 든다.

이런 과정이 하나씩 쌓이다보면 언제간 파이썬으로 내가 하고자하는 많은 프로그램들을 만들 수 있지 않을까하는 기대도된다.

정리하고 보니 파이썬에서 내장으로 지원하는 collection이나 itertools에 대해서도 정리하면 좋을것 같다.

항상 잘쓰면 가독성있고 성능도 좋지만 왜 좋은지를 설명을 못했으니.. 이 기회에 정리하고 가면 좋을것같다.

0개의 댓글