포스트

NumPy 기초 (1) - Why we use NumPy

NumPy 배열이 왜 빠르고 메모리를 효율적으로 사용하는지, vectorization과 dtype을 중심으로 정리합니다.

NumPy 기초 (1) - Why we use NumPy

NumPy를 기초부터 정리하는 시리즈의 첫 글입니다. 시리즈 소개와 실습 환경 준비는 0편을 참고하세요.

NumPy 소개

NumPy(Numerical Python)은 Python에서 수치, 행렬 계산 등을 효율적으로 처리하기 위한 라이브러리입니다. NumPy를 사용하는 여러 이유가 있지만 큰 이유는 아래 세 가지입니다.

  1. Vectorization: Python 반복문 없이 배열 전체를 한 번에 계산할 수 있습니다.
  2. Memory efficiency: 같은 자료형의 데이터를 조밀하게 저장합니다.
  3. dtype: 배열 원소의 자료형과 크기를 명확하게 지정할 수 있습니다.

이외에도 풍부한 생태계, C 기반 구현 등 여러 이유가 있습니다.

Python List는 뭐가 문제였는가?

Python에는 내장 자료형인 리스트가 있습니다. 리스트는 각 요소(element)를 객체(object)로 관리하고, 값 자체가 아니라 그 객체들을 가리키는 포인터(pointer)의 배열로 저장합니다.

문제는 파이썬에서는 정수 하나도 객체라는 점입니다. 1이라는 값은 숫자 4 byte에 타입 정보, 참조 카운트 같은 관리 정보가 붙어 28byte를 차지합니다. 그리고 리스트는 이 객체들을 직접 담지 않고, 주소만 담는 Pointer 배열입니다. 값은 객체로 포장되고 리스트는 Pointer만 담는 이중 구조가, 파이썬이 기본적으로 메모리를 많이 쓰는 이유이기도 합니다.

1
2
3
4
import sys

print(sys.getsizeof(1))         # 28
print(sys.getsizeof([1,2,3,4])) # 88

이 코드를 실행하면, Python에선 88byte를 차지합니다. 그마저도 Pointer 배열 부분만이고, list 내 원소 객체들은 별도입니다. C에선 16 byte 짜리 배열이지만 Python에선 5배 정도가 됩니다.

sys.getsizeof()의 결과는 Python 구현과 버전에 따라 달라질 수 있습니다. 중요한 점은 정확한 byte 수가 아니라, 리스트의 숫자들이 각각 별도의 객체로 관리된다는 것입니다.

리스트의 실제 구조를 그리면 이렇습니다.

1
2
3
4
5
6
7
Python list
┌─────────┬─────────┬─────────┐
│reference│reference│reference│
└────┬────┴────┬────┴────┬────┘
     │         │         │
     ▼         ▼         ▼
  int(1)    int(2)    int(3)

이 구조에서 100만 개 숫자를 더한다는 것은:

  1. Pointer를 따라가 객체를 찾고 (pointer chasing)
  2. 객체마다 타입을 확인하고 (dynamic typing overhead)
  3. 결과를 새 객체로 만드는 (object creation overhead)

위 세 작업을 100만 번 반복하기 때문에 실행 시간이 오래 걸리게 됩니다.

C가 빠르고 Python이 느린 것은 실행 방식의 차이입니다. C는 실행 전에 소스 전체가 기계어로 번역되는 컴파일 언어입니다. 변수의 타입도 컴파일 시점에 전부 결정됩니다. 그래서 100만 개를 더하는 반복문은 “메모리에서 4byte를 읽고, 더하고, 다음 칸으로”라는 기계어 몇 줄이 되고, CPU는 이것을 그대로 반복합니다. 반면, Python은 실행할 때 소스를 byte코드로 한 번 컴파일하고, 그 byte코드를 인터프리터가 한 명령씩 해석하며 실행합니다. 덧셈 하나마다 “이 값의 타입이 무엇인지, 이 타입의 덧셈이 무엇인지”를 매번 확인하고, 결과도 매번 새 객체로 만들기 때문에 작업 시간이 오래 걸리게 됩니다.

이러한 문제를 해결하기 위해 NumPy는 반복문을 Python 밖으로 넘깁니다. NumPy의 C 코드는 설치 시점에 이미 기계어로 컴파일되어 있고, import numpy가 이 기계어를 메모리에 올립니다. 배열 연산을 부를 때마다 Python에서 이 기계어 함수로 제어가 넘어가므로, 100만 개를 도는 반복문에 인터프리터가 개입하지 않아 실행시간이 단축됩니다.

Vectorization

벡터화(Vectorization)는 원소를 하나씩 도는 반복문 없이 배열 전체를 하나의 연산으로 처리하는 방식입니다. 이것이 가능한 이유는 ndarray의 구조에 있습니다. ndarray(N-dimensional array)는 NumPy의 핵심 자료구조로, 배열 내부의 모든 원소가 같은 자료형이어야 합니다. 그 대신 원소를 객체로 관리하지 않고, 값 자체를 하나의 연속된 메모리에 저장합니다.

1
2
3
import numpy as np

arr = np.array([1, 2, 3], dtype=np.int64)
1
2
3
4
NumPy ndarray
┌─────────┬─────────┬─────────┐
│ int64 1 │ int64 2 │ int64 3 │
└─────────┴─────────┴─────────┘
  • 원소가 객체가 아니라 raw 값으로, 메모리에 연속으로 저장됩니다.
  • 타입이 배열 전체에 한 번만 기록되므로(dtype), 원소마다 타입 확인이 필요 없습니다.
  • 벡터화를 할 경우, 반복문 전체를 컴파일된 C 코드가 한 번에 돌 수 있고, CPU 캐시와 SIMD 명령의 이점도 그대로 받습니다.

먼저 메모리 사용량을 비교해 보겠습니다.

1
2
3
4
5
6
7
8
import sys
import numpy as np

lst = list(range(1_000_000))
arr = np.arange(1_000_000, dtype=np.int64)

print(sys.getsizeof(lst))   # 8000056, Pointer 배열 부분만
print(arr.nbytes)           # 8000000, 값 전체

리스트는 여기에 int 객체 100만 개가 별도로 붙지만, 배열은 800만 byte가 전부입니다.

실행 시간도 직접 재보면 차이가 극적입니다.

1
2
3
4
5
6
7
8
9
10
import numpy as np

arr = np.arange(1_000_000)
lst = list(range(1_000_000))

# 벡터화 - C 레벨 반복
%timeit (arr * 2).sum()          # 약 1 ms

# Python 반복문 - 원소마다 인터프리터 개입
%timeit sum(x * 2 for x in lst)  # 약 60 ms

환경에 따라 수치는 다르지만 대체로 수십~수백 배 차이가 납니다. 주의할 점은 NumPy 배열을 쓴다고 항상 빨라지는 게 아닙니다. for x in arr:처럼 ndarray 배열을 Python 반복문으로 돌면 리스트보다 오히려 느립니다. 원소를 꺼낼 때마다 NumPy 값이 Python 객체로 포장되는 비용이 추가되기 때문입니다. NumPy가 Python list보다 빠른 경우는 배열 단위 연산을 할 때입니다.

dtype

NumPy의 자료구조(Container)는 ndarray 하나입니다. 리스트, 튜플처럼 여러 개가 아니라 배열 하나로 승부하고, 대신 배열 안에 담기는 원소의 자료형(dtype) 이 여러 종류입니다.

ndarray는 dtype(원소의 자료형)과 shape(모양)로 구성됩니다. dtype은 원소 하나를 몇 byte에 어떤 형식으로 저장할지를 정하고, shape는 배열이 각 차원에 몇 칸인지를 나타냅니다.

dtype크기담을 수 있는 값
int81byte-128 ~ 127
int162byte-32,768 ~ 32,767
int324byte약 ±21억
int648byte약 ±922경 (정수 기본값)
float324byte유효숫자 약 7자리
float648byte유효숫자 약 16자리 (실수 기본값)
bool1byteTrue / False

여기서 NumPy는 본인이 정의한 dtype의 범위에서 벗어난 값은 가질 수 없습니다. 가령 int8의 ndarray에 1000을 넣을 수는 없습니다. 또 dtype을 너무 크게 선언을 하면 메모리가 낭비되기 떄문에 따라서 data size에 맞게 코딩을 해야 합니다.

1
2
3
arr = np.array([1, 2, 3], dtype=np.int8)
# arr[0] = 1000                     # OverflowError: Python integer 1000 out of bounds for int8
# np.array([1000], dtype=np.int8)   # 같은 에러

반대로 담을 값이 작은데 dtype을 크게 잡으면 그만큼 메모리가 낭비됩니다. 시험 점수(0~100)라면 int8 1byte로 충분한 것을 int64는 8byte씩 씁니다. 데이터의 범위에 맞춰 dtype을 고르는 것이 기본입니다.

값의 범위가 맞지 않을 때는 dtype을 바꿔서 해결합니다. 더 큰 타입으로 올리는 것이 업캐스팅(upcasting), 더 작은 타입으로 내리는 것이 다운캐스팅(downcasting)입니다. 다운캐스팅은 주의가 필요합니다. 생성이나 대입과 달리 astype 변환은 범위를 벗어나도 에러를 내지 않고 값을 조용히 뒤틀기 때문입니다.

1
np.array([1000]).astype(np.int8)   # array([-24], dtype=int8), 에러 없이 값이 뒤틀림

다음 편에서는 ndarray를 실제로 다루는 기본 문법을 정리합니다. 생성, indexing, 연산, aggregation부터 NaN 처리까지를 다룹니다.

다음 글: NumPy 기초 (2) - Basic Grammar

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.