ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Python 실행속도 개선 방법
    Python 2021. 10. 4. 18:38
    반응형

    python 코드 최적화와 관련한 좋은 포스팅을 발견하여 참고를 위해 번역하여 정리해 놓는다. (https://towardsdatascience.com/10-techniques-to-speed-up-python-runtime-95e213e925dc)

     

     

    [최적화에 앞서 고려할 부분]

    1. 정상적으로 작동하는 명확하고 가독성 좋은 코드를 작성하는 것이 최적화의 전제조건이다. 명확하고 가독성 좋은 코드의 속도를 개선하는 것이 이해하기 어렵고 가독성 좋지 않은 코드를 개선하는 것보다 훨씬 쉽기 때문이다.
    2. 최적화는 비용을 수반한다. 대체로 시간복잡도와 공간복잡도는 반비례 관계가 된다. 실행시간을 줄이기 위해서는 메모리를 더 사용하게 되고 메모리 사용을 줄이고자 하면 실행시간이 늘어날 수밖에 없다.
    3. 코드 작성의 우선순위는 최적화 보다 가독성을 확보하는데 둔다. 최적화를 위해 코드의 가독성을 떨어트려서는 안 된다.

     

    1. 적절한 자료구조의 사용

    1.1 in 연산을 사용하여 특정 데이터가 자료구조 안에 있는지 찾고자 할 때 적합한 자료구조는 set이다. list에서 자료를 찾는데 필요한 시간 복잡도는 O(n)이지만 set에서 자료를 찾는 데는 O(1) 밖에 소요되지 않는다.

     

    1.2 딕셔너리를 사용할 때는 데이터 초기화 작업이 dict 보다 빠른 defaultdict를 사용하도록 한다.

     

    2. 리스트컴프리핸션을 제네레이터 표현으로 대체한다.

    제너레이터 표현식은 이터레이터를 메모리에 저장하지 않고 결과를 얻을 수 있어 공간 복잡도를 줄이는 효과도 있다.

    import sys
    
    # 리스트 컴프리핸션 (bad)
    nums_sum_list_comprehension = sum([num ** 2 for num in range(1000000)])
    
    # 제네레이터 표현식 (good)
    nums_sum_generator_expression = sum((num ** 2 for num in range(10000000)))
    
    # Bad
    nums_squared_list = [num ** 2 for num in range(1000000)]
    print(sys.getsizeof(nums_squared_list))  # 87632
    
    # Good
    nums_squared_generator = (num ** 2 for num in range(1000000))
    print(sys.getsizeof(nums_squared_generator))  # 128

     

    3. 글로벌 변수는 로컬 변수로 대체한다.

    글로벌 변수의 사용은 시스템의 심각한 오류를 야기할 수 있으며, 실행 속도 또한 로컬 변수에 비해 느리므로 불가피한 경우가 아니라면 로컬변수로 사용하도록 한다.

     

     

    4. dot 연산을 피한다.

    4.1 import한 라이브러리 가운데 코드에서 자주 쓰이는 function의 경우는 from {라이브러리명} import {함수명}과 같이 직접적으로 함수까지 지정하여 dot 연산을 피할 수 있다. 이렇게 하면 매번 dot 연산 시에 호출되는 __getattribute__()나 __getattr__() 호출을 막아 실행 시간을 줄일 수 있다.

    import math
    from sys import stdin as ss
    r_line = ss.readline
    s = r_line()
    print(s)
    
    sqrt = math.sqrt
    print(sqrt(4))

     

    4.2 클래스 프로퍼티를 위해 사용하는 dot 연산 역시 위와 같은 맥락이다. 사용을 최소화할수록 속도가 올라간다.

    # 개선 전
    class Car:
        def __init__(self, seq, run_dist):
            self.seq = seq
            self.run_dist = run_dist
         
        def add_run_dist():
            for _ in range(100000):
                self.run_dist += 1	# 매번 dot 연산을 통해 클래스 속성에 접근
    # 개선 후
    class Car:
        def __init__(self, seq, run_dist):
            self.seq = seq
            self.run_dist = run_dist
         
        def add_run_dist():
            r_dist = self.run_dist
            for _ in range(100000):
                r_dist += 1	# 매번 dot 연산을 통해 클래스 속성에 접근
            self.run_dist = r_dist

     

     

    5. 불필요한 추상화를 피한다. 

    필요한 코드들을 하나로 묶기 위해 사용되는 데코레이터, 클래스 속성 접근, 디스크립터 등도 코드의 실행속도를 저하시킬 수 있다. 대부분의 경우에서 추상화가 필요한지 다시 한번 생각해볼 필요가 있다.

    파이썬은 C/C++ 프로그래머들이 클래스 속성에 접근하기 위해 사용하는 getter/setter 스타일보다 더 간단한 문법을 제공한다.

    # Bad: 559ms
    class DemoClass:
        def __init__(self, value: int):
            self.value = value
    
        @property
        def value(self) -> int:
            return self._value
    
        @value.setter
        def value(self, x: int):
            self._value = x
    
    def main():
        size = 1000000
        for i in range(size):
            demo_instance = DemoClass(size)
            value = demo_instance.value
            demo_instance.value = i
    
    main()
    # Good: 318ms
    class DemoClass:
        def __init__(self, value: int):
            self.value = value  ###
    
    def main():
        size = 1000000
        for i in range(size):
            demo_instance = DemoClass(size)
            value = demo_instance.value
            demo_instance.value = i
    
    main()

     

     

    6. 데이터 중복을 피한다.

    6.1 의미 없는 데이터 복사를 피한다.

    # Bad: 8.27s
    def main():
        size = 10000
        for _ in range(size):
            value = range(size)
            value_list = [x for x in value]  ###
            square_list = [x * x for x in value_list]  ###
    
    main()

    위의 코드에서 value_list는 의미가 없다. 따라서 다음과 같이 코드를 수정할 수 있다.

    # Good: 6.01s
    def main():
        size = 10000
        for _ in range(size):
            value = range(size)
            square_list = [x * x for x in value]  ###
    
    main()

     

    6.2 두 변수 값 swap 시 temp 변수는 필요하지 않다.

    대부분의 다른 언어들과 다르게 파이썬에서는 두 변수 간 데이터 swap은 다음과 같이 할 수 있다.

     

    a = 10
    b = 15
    a, b = b, a
    print(f'a={a}, b={b}')

     

    6.3 문자열을 붙일 때는 + 연산 대신 join()을 사용한다.

    # Bad: 4.29s
    import string
    from typing import List
    
    def concatString(string_list: List[str]) -> str:
        result = ''
        for str_i in string_list:
            result += str_i  ###
        return result
    
    def main():
        string_list = list(string.ascii_letters * 100)
        for _ in range(10000):
            result = concatString(string_list)
    
    main()

    파이썬의 str 타입 객체는 그 내용을 변경할 수 없는 객체이기 때문에 두 str 타입의 객체를 + 연산하는 경우 각각의 문자열을 새로운 메모리 공간에 복사하여 작업을 수행하게 된다. join()을 사용하게 되면 문자열 병합에 필요한 총 메모리 공간을 미리 계산한 뒤 필요한 메모리를 확보 후 해당 공간에 각각의 문자열을 복사하여 실행시간을 줄일 수 있다.

    # Good: 326ms
    import string
    from typing import List
    
    def concatString(string_list: List[str]) -> str:
        return ''.join(string_list)  ###
    
    def main():
        string_list = list(string.ascii_letters * 100)
        for _ in range(10000):
            result = concatString(string_list)
    
    main()

     

     

    7. if문에 두 개 이상의 조건이 주어질 때 각 조건들 사이에  논리연산자에 따라 조건의 순서를 변경해준다.

    • if condition1 and condition2: condition 간에 논리 연산이 and 인 경우 condition 중에 False 값을 많이 가진 condition을 condition1에 오도록 하면 뒤 따라오는 condition2에 대한 확인 작업을 피할 수 있다.
    • if condition1 or condition2: condition 간에 or 연산이 적용된 경우 condition 중에 True 값을 많이 가진 condition을 condition1에 오도록 하면 뒤따라오는 condition2에 대한 확인 작업을 피할 수 있다.

     

     

    8. 반복문 최적화

    8.1 while 문은 for 문으로 대체한다.

    # Bad: 8.70s
    def computeSum(size: int) -> int:
        sum_ = 0
        i = 0
        while i < size:
            sum_ += i
            i += 1
        return sum_
    
    def main():
        size = 10000
        for _ in range(size):
            sum_ = computeSum(size)
    
    main()

     

    for문이 while문 보다 빠르게 실행된다. 반복문의 실행 도중에 반복문의 종료 조건을 계산하는 경우가 아니라면 for 문을 사용하도록 한다.

    # Good: 4.91s
    def computeSum(size: int) -> int:
        sum_ = 0
        for i in range(size):  ### explicit for loop
            sum_ += i
        return sum_
    
    def main():
        size = 10000
        for _ in range(size):
            sum_ = computeSum(size)
    
    main()

     

    8.2 명시적 for문은 암시적 for문으로 변경한다. 즉, 직접 인덱싱 하지 않고 이터레이션을 사용하여 반복문에서 사용할 요소에 접근한다.

     

    위의 explicit for loop 코드를 implict for loop로 변경하여 성능을 개선한다.

    # Good: 1.60s
    def computeSum(size: int) -> int:
        return sum(range(size))  ### implicit for loop
    
    def main():
        size = 10000
        for _ in range(size):
            sum = computeSum(size)
    
    main()

     

    8.3 반복문 내부의 연산을 줄인다.

    # Bad: 13.2s
    import math
    
    def main():
        size = 10000
        sqrt = math.sqrt
        for x in range(size):
            for y in range(size):
                z = sqrt(x) + sqrt(y)  ###
    
    main()

    위의 예를 보면 이중 반복문 안에서 가장 안쪽에 sqrt()를 두 번 사용하고 있다. sqrt() 호출 중 x에 해당하는 것을 첫 번째 for문의 scope로 옮겨주는 것으로 실행 속도를 개선할 수 있다.

    # Good: 4.91s
    import math
    
    def main():
        size = 10000
        sqrt = math.sqrt
        for x in range(size):
            sqrt_x = sqrt(x)  ### 
            for y in range(size):
                z = sqrt_x + sqrt(y)
    
    main()

     

     

    9. numba.jit를 사용한다.

    numba는 파이썬 코드를 실행할 수 있는 기계어 코드로 실시간 변환해준다. 이를 통해 코드의 실행 속도를 크게 개선할 수 있다.

    # Bad: 5.95s
    def computeSum(size: float) -> int:
        sum = 0
        for i in range(size):
            sum += i
        return sum
    
    def main():
        size = 10000
        for _ in range(size):
            sum = computeSum(size)
    
    main()

    위의 코드 실행하기 전에 numba를 import 한다.

    # Good: 2.27s
    import numba
    
    @numba.jit
    def computeSum(size: float) -> int:
        sum = 0
        for i in range(size):
            sum += i
        return sum
    
    def main():
        size = 10000
        for _ in range(size):
            sum = computeSum(size)
    
    main()

     

     

    10. cProfile 모듈을 사용하여 함수의 실행 시간을 확인할 수 있다.

    cProfile.run()은 지정된 함수의 실행 시간을 출력한다.

     

    import cProfile
    
    def computeSum(size: int) -> int:
        return sum(range(size)) 
    
    def main():
        size = 10000
        for _ in range(size):
            sum = computeSum(size)
    
    if __name__ == "__main__":
        cProfile.run("main()")
        
    # Output
    """
             20004 function calls in 1.467 seconds
       Ordered by: standard name
       ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        10000    0.005    0.000    1.464    0.000 <stdin>:1(computeSum)
            1    0.002    0.002    1.467    1.467 <stdin>:1(main)
            1    0.000    0.000    1.467    1.467 <string>:1(<module>)
            1    0.000    0.000    1.467    1.467 {built-in method builtins.exec}
        10000    1.459    0.000    1.459    0.000 {built-in method builtins.sum}
            1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
    """

    특정 코드 구간에 대한 실행시간을 확인할 경우는 https://camel-it.tistory.com/97 를 참고

    'Python' 카테고리의 다른 글

    빠른 리스트 객체 복사  (0) 2021.11.20
    itertools - product(), permutations(), combinations()  (0) 2021.09.01
    all(), any() 함수  (0) 2021.08.31
    여러 iterable 객체를 묶어주는 zip 내장함수  (0) 2021.07.02
    for ~ else 문  (0) 2021.07.02

    댓글

Designed by Tistory.