본문 바로가기
Python/Numpy

넘파이 속도는 왜 빠를까?

by 컴돈AI 2024. 3. 31.

목차

    넘파이 속도는 왜 빠를까?

    • 넘파이는 CPU 캐시 효율적 활용, 최적화된 C언어 기반의 구현, 멀티스레딩과 병렬 처리 등의 방식을 이용하여 훨씬 더 빠른 연산을 가능하게 하였습니다.

    CPU 캐시 효율적 활용

    • 순수 Python 리스트는 포인터 배열을 사용하여 각 요소의 위치를 가리키지만 넘파이의 경우 연속된 메모리 블록에 데이터를 저장합니다.
    • 따라서 CPU 캐시의 활용도를 높일 수 있고, 결과적으로 연산 속도를 크게 개선합니다.

    최적화된 C언어 기반의 구현

    • Numpy는 내부적으로 C언어로 작성된 고성능 수학 라이브러리를 사용합니다.
    • 이 라이브러리들은 대량의 데이터를 효율적으로 처리하기 위해 최적화 되어 있어 연산 속도를 향상시켜줍니다.
    • 속도 향상 예시(Vectorization)
      • import numpy as np
        import time
        
        a = np.random.rand(10000000)
        b = np.random.rand(10000000)
        
        start_time = time.time()
        c = np.dot(a, b)
        end_time = time.time()
        
        print(f"Vectorized version : {end_time - start_time}초")
        
        
        c = 0
        start_time = time.time()
        for i in range(10000000):
            c += a[i] * b[i]
        end_time = time.time()
        print(f"For loop : {end_time - start_time}초")
        
        -->
        
        Vectorized version : 0.005002021789550781초
        For loop : 1.9364345073699951초
      • 동일한 연산이지만 엄청난 속도차이가 난다는 것을 확인할 수 있습니다. 따라서 for loop를 이용한 연산이 있을 때 벡터화를 시켜서 넘파이를 통한 연산을 진행하면 훨씬 시간절약을 할 수 있습니다.

    멀티스레딩과 병렬 처리 

    • 기존의 Python은 GIL로 인해 CPU Bound 작업에서는 단일 프로세스에서 여러 개의 스레드를 생성하더라도 속도 향상을 경험할 수 없습니다.
      • Python의 멀티 스레딩은 CPU 작업에서 GIL로 인해 단일 코어로 한 번에 하나의 스레드씩 번갈아가며 실행됩니다. (여러 개의 스레드를 이용하여 코드를 작성하더라도 단일 코어로 진행)
      • 단, I/O 바운드 작업이나 C확장을 사용하는 경우에는 멀티코어 환경의 이점을 활용할 수 있습니다.
    • 하지만 일부 Python 라이브러리는 내부적으로 C나 C++로 작성되어 종종 GIL을 잠시 해제하고 CPU 바운드 작업을 병렬로 처리할 수 있게 해 줍니다. 넘파이는 C언어 기반의 구현이므로 GIL을 해제하여 여러 개의 코어를 사용하는 환경에서 속도향상을 이뤄 낼 수 있습니다.
    • 넘파이가 연산에서 CPU 코어 제한으로 멀티코어를 활용하는지 확인하기
      • CPU 0번 코어만 사용하도록 제한
        import psutil
        import os
        import numpy as np
        import time
        
        # CPU Affinity를 현재 프로세스에 대해 단일 코어로 설정
        p = psutil.Process(os.getpid())
        p.cpu_affinity([0])  # 예: CPU 0번 코어만 사용
        
        # Numpy 연산 수행 및 시간 측정
        start_time = time.time()
        A = np.random.rand(5000, 5000)
        B = np.random.rand(5000, 5000)
        C = np.dot(A, B)
        end_time = time.time()
        
        print(f"단일 코어에서 실행 시간: {end_time - start_time}초")
        
        -->
        단일 코어에서 실행 시간: 4.240957736968994초
        • 약 4.24초의 시간 소요
      • CPU 제한 없이 실행
        • import numpy as np
          import time
          
          # Numpy 연산 수행 및 시간 측정
          start_time = time.time()
          A = np.random.rand(5000, 5000)
          B = np.random.rand(5000, 5000)
          C = np.dot(A, B)
          end_time = time.time()
          
          print(f"모든 코어에서 실행 시간: {end_time - start_time}초")
          
          -->
          모든 코어에서 실행 시간: 0.9202046394348145초
        • 약 0.92초의 시간 소요
      • 동일한 코드지만 CPU 제한을 통해 넘파이가 멀티스레딩 방식으로 멀티코어를 활용하여 CPU Bound 작업에서 병렬로 연산한다는 것을 확인할 수 있습니다.
    • 참고 : 기존 Python의 경우 멀티 스레드로 코드를 작성하더라도 CPU 제한했을 때와 CPU를 제한하지 않았을 때 시간 차이가 거의 없습니다.(GIL로 인해 멀티스레드로 작성하더라도 단일코어로 여러 스레드를 번갈아가며 계산)
      더보기
      • CPU 0번 코어만 사용하도록 제한
        • import threading
          import time
          import numpy as np
          
          import psutil
          import os
          
          # CPU Affinity를 현재 프로세스에 대해 단일 코어로 설정
          p = psutil.Process(os.getpid())
          p.cpu_affinity([0])  # 예: CPU 0번 코어만 사용
          
          
          def matrix_multiply_thread(A, B, result, row_range):
              for i in row_range:
                  for j in range(len(B[0])):
                      for k in range(len(B)):
                          result[i][j] += A[i][k] * B[k][j]
          
          
          def generate_matrix(size):
              return np.ones((size, size))
          
          
          size = 200  # 200x200 행렬로 설정
          A = generate_matrix(size)
          B = generate_matrix(size)
          result = np.zeros((size, size))
          
          
          # 멀티스레딩을 위한 준비
          num_threads = 4
          threads = []
          row_ranges = [
              (i * size // num_threads, (i + 1) * size // num_threads) for i in range(num_threads)
          ]
          
          # 행렬 곱셈 시간 측정 시작
          start_time = time.time()
          
          # 멀티스레딩으로 행렬 곱셈 실행
          for i in range(num_threads):
              thread = threading.Thread(
                  target=matrix_multiply_thread,
                  args=(A, B, result, range(row_ranges[i][0], row_ranges[i][1])),
              )
              threads.append(thread)
              thread.start()
          
          for thread in threads:
              thread.join()
          
          # 행렬 곱셈 시간 측정 종료
          end_time = time.time()
          
          print(f"단일 코어에서 실행 시간: {end_time - start_time}초")
          
          -->
          단일 코어에서 실행 시간: 3.9388818740844727초
        • 약 3.94초의 시간 소요
      • CPU 제한 없이 실행
        • import threading
          import time
          import numpy as np
          
          
          def matrix_multiply_thread(A, B, result, row_range):
              for i in row_range:
                  for j in range(len(B[0])):
                      for k in range(len(B)):
                          result[i][j] += A[i][k] * B[k][j]
          
          
          def generate_matrix(size):
              return np.ones((size, size))
          
          
          size = 200  # 200x200 행렬로 설정
          A = generate_matrix(size)
          B = generate_matrix(size)
          result = np.zeros((size, size))
          
          
          # 멀티스레딩을 위한 준비
          num_threads = 4
          threads = []
          row_ranges = [
              (i * size // num_threads, (i + 1) * size // num_threads) for i in range(num_threads)
          ]
          
          # 행렬 곱셈 시간 측정 시작
          start_time = time.time()
          
          # 멀티스레딩으로 행렬 곱셈 실행
          for i in range(num_threads):
              thread = threading.Thread(
                  target=matrix_multiply_thread,
                  args=(A, B, result, range(row_ranges[i][0], row_ranges[i][1])),
              )
              threads.append(thread)
              thread.start()
          
          for thread in threads:
              thread.join()
          
          # 행렬 곱셈 시간 측정 종료
          end_time = time.time()
          
          print(f"모든 코어에서 실행 시간: {end_time - start_time}초")
          
          -->
          모든 코어에서 실행 시간: 3.910187244415283초
        • 약 3.91초의 시간 소요
      • 코어 제한을 했을 때와 코어 제한을 하지 않았을 경우 시간 차이가 거의 없습니다. 이는 파이썬이 CPU Bound 작업에서 멀티 스레드 방식으로 코드를 작성하더라도, GIL로 인해 멀티코어 환경에서도 단일코어로 여러 스레드가 번갈아가며 실행됩니다. 따라서 성능향상이 일어나지 않습니다.

    'Python > Numpy' 카테고리의 다른 글

    assert를 이용해 shape/차원 확인하기  (0) 2024.03.31
    Rank 1 배열 사용하지 말기  (1) 2024.03.31