파이썬의 내장 함수가 빠른 이유가 궁금했다.
알고리즘을 공부할때 이런저런 이유로 빠르다고 말만 들었지 왜 빠른지에 대해선 크게 관심이 없었다.
그러나 누군가에게 코드리뷰를 할때 이것에 대해서 이유를 묻는다면?
딱히 논리적으로 설명해줄 방법이 떠오르지 않아 이 글을 쓰게 되었다.
함수 네이밍이 구린건 아직 실력이 부족한 부분이라 양해바랍니다....
간단하게 덧셈하는 상황을 가정하였다.
파이썬의 반복문인 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_FUNCTION과 LIST_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
동일 상황에서 for와 while은 생각보다 차이가 나지 않는다. 다만 반복만 같지 for와 while의 사용처가 다르니 구분해서 사용할것
list comprehension을 사용할 수 있는 상황이면 for문 보다 더 애용해서 사용하자! 물론 가독성을 고려해서 판단해야한다.
내장함수에서 제공되는 기능이라면 코드로 만들지 말고 적극적으로 내장함수를 사용하자!
이렇게 글로 정리해놓으니 누군가 물어볼때 대략적이나마 뭐라고 말해줘야 될지 알것같다.
파이썬으로 밥먹고 사는데 누가 이거 왜 빠른가요?라고 했을때 바보같이 그냥 빨라요! 하면 뭔가 좀 그렇다....
물론 깔끔한 글도 아니고 뭔가 겉핡기식으로 파악해본것 같지만 이렇게 해보다보면 유의만 결과를 도출해낼 수 있지 않을까라는 막연한 생각이 든다.
이런 과정이 하나씩 쌓이다보면 언제간 파이썬으로 내가 하고자하는 많은 프로그램들을 만들 수 있지 않을까하는 기대도된다.
정리하고 보니 파이썬에서 내장으로 지원하는 collection이나 itertools에 대해서도 정리하면 좋을것 같다.
항상 잘쓰면 가독성있고 성능도 좋지만 왜 좋은지를 설명을 못했으니.. 이 기회에 정리하고 가면 좋을것같다.