본문 바로가기

Computer/Python

프로파일링 (5) - 바이트코드: 내부작동 이해하기

반응형

프로파일링 (1) - 줄리아 집합 (Julia set)

프로파일링 (2) - 함수 실행 시간 계산하기

프로파일링 (3) - cProfile 모듈

프로파일링 (4) - line_profiler


이번 포스트에서는 지금까지 알아본 파이썬 코드의 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"의 경우에는 최적화된 리스트 표현식 함수를 사용해서 중간 파이썬 객체를 생성하지 않고 최종 결과를 생성합니다. 따라서 최대한 파이썬 내장 함수를 사용해서 가독성을 해치지 않고 간결한 코드를 작성해야 합니다.
반응형