Skip to main content

성능 최적화

NumPy의 성능을 최대한 활용하려면 벡터화(Vectorization), 메모리 레이아웃, 효율적 연산 패턴을 이해해야 합니다. Python 반복문을 NumPy 연산으로 대체하는 것만으로도 10~100배의 속도 향상을 얻을 수 있습니다.

학습 목표

  • 벡터화의 원리를 이해하고 반복문을 벡터화 코드로 변환할 수 있다
  • C-order와 F-order 메모리 레이아웃의 차이를 이해한다
  • np.einsum()으로 복잡한 텐서 연산을 간결하게 표현할 수 있다
  • 대용량 배열 처리 전략을 적용할 수 있다

왜 중요한가

대규모 데이터셋을 다룰 때 연산 속도는 생산성에 직접적인 영향을 미칩니다. 100만 행의 데이터를 처리할 때 벡터화 코드는 반복문 대비 수십 배 빠르며, 메모리 레이아웃을 최적화하면 캐시 효율이 극적으로 향상됩니다.

벡터화 vs 반복문

import numpy as np
import time

# 100만 개 원소
arr = np.random.randn(1_000_000)

# 반복문 방식 (느림)
start = time.time()
result_loop = np.empty_like(arr)
for i in range(len(arr)):
    result_loop[i] = arr[i] ** 2 + 2 * arr[i] + 1
loop_time = time.time() - start

# 벡터화 방식 (빠름)
start = time.time()
result_vec = arr ** 2 + 2 * arr + 1
vec_time = time.time() - start

print(f"반복문: {loop_time:.4f}초")    # 약 0.5초
print(f"벡터화: {vec_time:.4f}초")     # 약 0.005초
print(f"속도비: {loop_time / vec_time:.0f}배")  # 약 100배

벡터화 변환 패턴

반복문 패턴벡터화 대체
for + 조건문np.where()
for + 누적np.cumsum(), np.cumprod()
이중 for (거리 계산)브로드캐스팅
for + if/elsenp.select()
for + 집계np.sum(axis=...)
# 예시: 조건부 변환
arr = np.random.randn(10000)

# 느린 방식
result = np.empty_like(arr)
for i in range(len(arr)):
    if arr[i] > 0:
        result[i] = arr[i] * 2
    else:
        result[i] = 0

# 빠른 방식 — np.where 활용
result = np.where(arr > 0, arr * 2, 0)

메모리 레이아웃

C-order vs F-order

arr = np.array([[1, 2, 3],
                [4, 5, 6]], order='C')  # 행 우선 (기본값)

# C-order: 행을 따라 연속 저장 [1, 2, 3, 4, 5, 6]
# F-order: 열을 따라 연속 저장 [1, 4, 2, 5, 3, 6]

# 메모리 레이아웃 확인
print(arr.flags['C_CONTIGUOUS'])  # True
print(arr.flags['F_CONTIGUOUS'])  # False
레이아웃저장 순서빠른 접근 방향사용
C-order (행 우선)행 → 열행 방향 순회NumPy 기본, 이미지
F-order (열 우선)열 → 행열 방향 순회FORTRAN, 일부 선형대수
# 성능 차이 확인
large = np.random.randn(10000, 10000)

# C-order: 행 방향 합계가 빠름
%timeit large.sum(axis=1)  # 빠름
%timeit large.sum(axis=0)  # 느림 (캐시 미스)

# strides 확인
print(large.strides)  # (80000, 8) — 다음 행까지 80000바이트, 다음 열까지 8바이트

contiguous 배열 만들기

# 슬라이싱 결과가 비연속적일 수 있음
arr = np.arange(12).reshape(3, 4)
col = arr[:, 1]  # 비연속 — stride가 16 (4 * 4바이트)

# 연속 배열로 변환
col_contiguous = np.ascontiguousarray(col)

np.einsum

np.einsum()은 아인슈타인 합 규칙(Einstein Summation Convention)으로 복잡한 텐서 연산을 간결하게 표현합니다.
A = np.random.randn(3, 4)
B = np.random.randn(4, 5)

# 행렬 곱셈
C = np.einsum('ij,jk->ik', A, B)  # A @ B와 동일

# 대각합 (trace)
M = np.random.randn(3, 3)
trace = np.einsum('ii->', M)  # np.trace(M)와 동일

# 원소별 곱의 합 (내적)
v1 = np.array([1, 2, 3])
v2 = np.array([4, 5, 6])
dot = np.einsum('i,i->', v1, v2)  # np.dot(v1, v2)와 동일

# 외적
outer = np.einsum('i,j->ij', v1, v2)  # np.outer(v1, v2)와 동일

# 배치 행렬 곱셈
A_batch = np.random.randn(10, 3, 4)
B_batch = np.random.randn(10, 4, 5)
C_batch = np.einsum('bij,bjk->bik', A_batch, B_batch)  # 10개 행렬 곱
einsum 표기연산동등 함수
'ij,jk->ik'행렬 곱셈A @ B
'ii->'대각합np.trace()
'i,i->'내적np.dot()
'i,j->ij'외적np.outer()
'ij->ji'전치A.T
'ij->'전체 합np.sum()
'ij->i'행별 합np.sum(axis=1)

대용량 배열 전략

# 메모리 사용량 확인
arr = np.random.randn(10000, 1000)
print(f"메모리: {arr.nbytes / 1024**2:.1f} MB")  # 약 76.3 MB

# dtype 다운캐스트로 메모리 절약
arr_32 = arr.astype(np.float32)
print(f"float32: {arr_32.nbytes / 1024**2:.1f} MB")  # 약 38.1 MB

# 메모리 매핑 — 디스크에서 직접 읽기
large_file = np.memmap('large_data.dat', dtype=np.float32,
                       mode='w+', shape=(100000, 1000))
large_file[:1000] = np.random.randn(1000, 1000).astype(np.float32)
del large_file  # 디스크에 flush

# 읽기 전용으로 열기
data = np.memmap('large_data.dat', dtype=np.float32,
                 mode='r', shape=(100000, 1000))
print(data[:5, :3])  # 전체를 메모리에 로드하지 않음

AI/ML에서의 활용

  • 배치 처리: 벡터화 연산으로 전체 배치를 한 번에 처리하여 학습 속도를 높입니다
  • 텐서 연산: einsum()으로 어텐션 메커니즘의 복잡한 텐서 연산을 구현합니다
  • 메모리 최적화: float32로 dtype을 줄여 GPU 메모리를 절약합니다
  • 대용량 데이터: memmap으로 메모리보다 큰 데이터셋을 처리합니다
반드시 그렇지는 않습니다. 간단한 연산(행렬 곱셈 등)에서는 @ 연산자가 BLAS 라이브러리를 직접 호출하므로 더 빠를 수 있습니다. einsum()은 복잡한 다차원 연산을 간결하게 표현할 때 주로 유용합니다. optimize=True 파라미터를 사용하면 연산 순서를 자동 최적화합니다.
np.vectorize()를 사용할 수 있지만, 이것은 내부적으로 반복문이므로 진정한 벡터화가 아닙니다. 성능이 중요하다면 Numba의 @jit 데코레이터나 Cython을 고려하세요.

체크리스트

  • 반복문 코드를 벡터화 코드로 변환할 수 있다
  • C-order와 F-order의 차이를 이해하고 접근 패턴에 맞게 선택할 수 있다
  • np.einsum()으로 기본적인 텐서 연산을 표현할 수 있다
  • dtype 다운캐스트와 memmap으로 메모리를 최적화할 수 있다
  • strides의 의미를 이해하고 캐시 효율적인 접근 패턴을 설계할 수 있다

다음 문서