프로파일링 (1) - 줄리아 집합 (Julia set)
이번 포스트에서는 지금까지 알아본 파이썬 코드의 CPU / RAM 사용량 측정 이외에 파썬의 가상 머신 (c파이썬) 에서 사용하는 내부 바이트코드에 대해 살펴보겠습니다. 파이썬 코드가 가상 머신 안에서 실제로 어떻게 동작하는지 이해하면, 느리게 동작하는 함수에 대해 더 깊은 파악이 가능합니다. 여기서 사용하는 모듈은 "dis" 라고 하는 파이썬 기본 내장 모듈로서 코드나 모듈을 넘기면 역어셈블 결과를 출력해줍니다. julia 코드에서 $z$를 계산하는 코드에 대해서만 역어셈플 결과를 살펴보면 다음과 같습니다.
>>> import dis
>>> import julia
>>> dis.dis(julia.calculate_z)
7 0 LOAD_CONST 1 (0)
2 BUILD_LIST 1
4 LOAD_GLOBAL 0 (len)
6 LOAD_FAST 1 (zs)
8 CALL_FUNCTION 1
10 BINARY_MULTIPLY
12 STORE_FAST 3 (output)
8 14 LOAD_GLOBAL 1 (range)
16 LOAD_GLOBAL 0 (len)
18 LOAD_FAST 1 (zs)
20 CALL_FUNCTION 1
22 CALL_FUNCTION 1
24 GET_ITER
>> 26 FOR_ITER 74 (to 102)
28 STORE_FAST 4 (i)
9 30 LOAD_CONST 1 (0)
32 STORE_FAST 5 (n)
10 34 LOAD_FAST 1 (zs)
36 LOAD_FAST 4 (i)
38 BINARY_SUBSCR
40 STORE_FAST 6 (z)
11 42 LOAD_FAST 2 (cs)
44 LOAD_FAST 4 (i)
46 BINARY_SUBSCR
48 STORE_FAST 7 (c)
12 >> 50 LOAD_FAST 5 (n)
52 LOAD_FAST 0 (maxiter)
54 COMPARE_OP 0 (<)
56 POP_JUMP_IF_FALSE 92
58 LOAD_GLOBAL 2 (abs)
60 LOAD_FAST 6 (z)
62 CALL_FUNCTION 1
64 LOAD_CONST 2 (2)
66 COMPARE_OP 0 (<)
68 POP_JUMP_IF_FALSE 92
13 70 LOAD_FAST 6 (z)
72 LOAD_FAST 6 (z)
74 BINARY_MULTIPLY
76 LOAD_FAST 7 (c)
78 BINARY_ADD
80 STORE_FAST 6 (z)
14 82 LOAD_FAST 5 (n)
84 LOAD_CONST 3 (1)
86 INPLACE_ADD
88 STORE_FAST 5 (n)
90 JUMP_ABSOLUTE 50
15 >> 92 LOAD_FAST 5 (n)
94 LOAD_FAST 3 (output)
96 LOAD_FAST 4 (i)
98 STORE_SUBSCR
100 JUMP_ABSOLUTE 26
16 >> 102 LOAD_FAST 3 (output)
104 RETURN_VALUE
첫 번째 열은 원래 소스 파일의 줄 번호르 ㄹ나타내고, 두 번째 열의 ">>" 기호는 코드의 다른 지점에서 점프해오는 지점입니다. 세 번째/네 번째 열은 각각 연산 주소, 연산 이름을 나타내며, 다섯 번째/여섯 번째는 연산에 전달하는 매개변수나 점프 대상 연산 주소를 나타냅니다. "calculate_z" 코드를 다시 살펴보면서 분석해 보도록 하겠습니다.
def calculate_z(maxiter, zs, cs):
output = [0] * len(zs) # 7
for i in range(len(zs)):
n = 0
z = zs[i]
c = cs[i]
while n < maxiter and abs(z) < 2: # 12
z = z*z + c
n += 1
output[i] = n
return output
- 7 번째 줄을 보면, 먼저 바이트코드는 상숫값 0을 스택에 넣고 리스트를 생성합니다. 그리고 네임스페이스에서 len 함수를 찾아 스택에 집어넣고 다시 네임스페이스에서 zs를 찾아 스택에 집어넣습니다. 8번째 줄에서 스택의 len 함수를 호출하는데, 이 함수는 스택에서 zs를 꺼내 사용합니다. 마지막 두 인자 (len, zs)에 대해 곱셈을 수행하고 output에 결과를 저장합니다.
- "JUMP ABSOLUTE"과 "POP_JUMP_IF_FALSE" 연산이 점프기호 ">>" 에 대응합니다.
- 12 번째 줄을 보면 ">>" 기호가 있는 것으로 보아 다른 지점에서 점프해 온 것을 알 수 있습니다. 다섯 번째 열을 보면 14 번째 줄의 "JUMP ABSOLUTE" 연산에서 점프해 온 것을 알 수 있습니다. 또한, 12 번째 줄의 다섯 번째 줄의 "POP_JUMP_IF_FALSE" 기호에서 92 연산 주소로 점프함을 알 수 있습니다.
바이트코드로 인한 속도 저하
정수 수열의 합을 구하는 다음 두 함수를 보겠습니다. 두 함수 모두 결과는 같으나 "fn2"는 내장함수 만을 이용해서 더 적은 수의 바이트코드를 사용할 것이라 짐작할 수 있고 timeit 모듈을 사용해서 보면 "fn2" 함수가 더 짧은 시간이 걸린 것을 볼 수 있습니다.
def fn1(upper=1000000):
total = 0
for n in range(upper):
total += n
return total
def fn2(upper=1000000):
return sum(range(upper))
>>> %timeit fn1()
65.7 ms ± 1.1 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
>>> %timeit fn2()
15.9 ms ± 1.37 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
실제로 dis 모듈을 사용해서 분석하면 내장 함수만을 사용한 "fn2" 함수는 연산을 6번 수행하지만 "fn1" 함수는 17번의 연산을 수행하게 됩니다.
>>> import dis
>>> dis.dis(fn1)
2 0 LOAD_CONST 1 (0)
2 STORE_FAST 1 (total)
3 4 SETUP_LOOP 24 (to 30)
6 LOAD_GLOBAL 0 (range)
8 LOAD_FAST 0 (upper)
10 CALL_FUNCTION 1
12 GET_ITER
>> 14 FOR_ITER 12 (to 28)
16 STORE_FAST 2 (n)
4 18 LOAD_FAST 1 (total)
20 LOAD_FAST 2 (n)
22 INPLACE_ADD
24 STORE_FAST 1 (total)
26 JUMP_ABSOLUTE 14
>> 28 POP_BLOCK
5 >> 30 LOAD_FAST 1 (total)
32 RETURN_VALUE
>>> dis.dis(fn2)
8 0 LOAD_GLOBAL 0 (sum)
2 LOAD_GLOBAL 1 (range)
4 LOAD_FAST 0 (upper)
6 CALL_FUNCTION 1
8 CALL_FUNCTION 1
10 RETURN_VALUE
- "fn1" 의 경우에는 "total, n" 이라는 두 개의 지역 변수로 루프를 순회하며, 루프가 계속될 때마다 두 번째 변수인 $n$의 타입을 검사하는 "total.__add__" (INPLACE_ADD 연산) 함수를 호출합니다.
- 이에 반해 "fn2"의 경우에는 최적화된 리스트 표현식 함수를 사용해서 중간 파이썬 객체를 생성하지 않고 최종 결과를 생성합니다. 따라서 최대한 파이썬 내장 함수를 사용해서 가독성을 해치지 않고 간결한 코드를 작성해야 합니다.
'Computer > Python' 카테고리의 다른 글
제너레이터와 yield (2) - 피보나치, 무한급수 (0) | 2022.09.11 |
---|---|
사전과 네임스페이스 (2) | 2022.09.09 |
프로파일링 (4) - line_profiler (0) | 2022.08.20 |
프로파일링 (3) - cProfile 모듈 (0) | 2022.08.20 |
프로파일링 (2) - 함수 실행 시간 계산하기 (0) | 2022.08.13 |