Python 에서 N차원 실수 형식의 배열(대체적으로 2차원 이미지 데이터, 2차원 GIS 수치 데이터, 1차원 벡터 데이터 등)을 고속으로 데이터를 처리하기 위해 몇 가지 방법을 알아보았었다. 가장 먼저 생각난 것은 Java 에서 경험해 보았던 JNI(Java Native Interface) 와 같이 C/C++ 로 제작된 바이너리와 이를 감싸는 인터페이스를 이용하여 구현하는 방식이었다. C/C++ 로 제작된 바이너리는 CPU 에서 직접 구동되는 네이티브 프로그램이므로 특수한 경우가 아닌 한 속도가 좀 더 잘 나올 것이다. 또한 병렬 프로그램을 위해 스레드 방식을 사용할 경우 OS 레벨의 스레드 API 를 직접 이용하므로 오버헤드도 적다. 그리고 C/C++ 언어에 쉽게 적용될 수 있는 OpenMP 를 이용하여 간편한 방법으로 데이터 병렬 처리를 구현할 수 있다. 하지만 역시 유지보수성이 문제이다. 한 번에 C/C++ 코드가 잘 작성되어 컴파일되고 배포되면 좋겠지만 작성, 컴파일, 배포 등 그 어느 단계에서도 작업의 일회성을 보장할 수 없다.
Cython 을 이용하여 C/C++ 언어를 사용하지 않고도(또는 사용량을 줄이면 서도) 네이티브 루틴을 작성할 수 있는 방법이 있다. 얼마전 작성한 python+cython+numpy+openmp+pyximport 에서 언급한 바와 같이 간단한 방법으로 OpenMP 를 적용할 수 있다. C/C++ 언어로 연산 집약적인 부분을 작성하고 Python 과 연결하는 작업 및 향후 유지보수 상황을 고려하면 훨씬 간편한 방법이다. 하지만 Cython 을 사용할 수 밖에 없는 특별한 경우가 아니라면 numba 를 사용하는 것이 훨씬 간편하다.
아래의 코드는 ufunc 를 정의하는 코드이다. 각 배열 요소에 대해 CPU 의 모든 코어를 이용하여 연산을 수행한다. func1 함수의 매개변수인 x, y는 element-wise 형식으로서 스칼라 값이다.
import math
from numba import vectorize, float32
@vectorize([float32(float32, float32)], target='parallel')
def func1(x, y):
return math.atan2(x, y)
위 방법으로 정의된 func1 함수는 아래와 같은 방법으로 사용하면 된다.
import numpy
x = numpy.arange(100000000, dtype=numpy.float32)
y = numpy.arange(100000000, dtype=numpy.float32)
z = func1(x, y)
atan2 함수는 numpy 의 ufunc 로 이미 구현되어 있으며 아래와 같은 방법으로 사용한다.
import numpy
x = numpy.arange(100000000, dtype=numpy.float32)
y = numpy.arange(100000000, dtype=numpy.float32)
z = numpy.arctan2(x, y)
func1 의 결과와 numpy.atan2 의 결과는 동일할 것이며 아래는 이를 확인하는 완전한 코드이다.
import math
import numpy
from numba import vectorize, float32
@vectorize([float32(float32, float32)], target='parallel')
def func1(x, y):
return math.atan2(x, y)
x = numpy.arange(100000000, dtype=numpy.float32)
y = numpy.arange(100000000, dtype=numpy.float32)
z1 = func1(x, y)
z2 = numpy.arctan2(x, y)
assert numpy.all(z1 == z2)
1억개의 엘리먼트를 갖는 임의의 배열 2개를 생성한다.(10000 x 10000 즉 1억 화소의 이미지 두 장이라고 하면 어느 정도의 크기인지 느낌이 온다) 그리고 numba 로 정의한 ufunc 인 func1 을 이용하여 atan2 를 계산하고, numpy 내장 ufunc 인 arctan2 를 이용하여 마찬가지로 atan2 를 계산한다. 그리고 생성된 두 개의 값이 동일한지 비교함으로서 func1 의 정상 동작 및 정상 구현 여부를 확인한다. 구동 결과는 예상대로 이며, 중요한 점은 func1 은 target='parallel'로 정의 되어있으므로 CPU의 모든 코어를 이용하여 병렬 처리되며, numpy.arctan2는 싱글 스레드 기반이므로 한 개의 코어만을 이용하여 처리된다. 4코어 8스레드를 가진 본 PC에서 func1 수행시 CPU 사용률은 100% 가까이 올라가며 0.2 초 만에 계산이 완료되었다. 반면 numpy.arctan2 를 수행할 때 CPU 사용률은 최대 약 15%를 넘지 못했으며 2초 정도 소요되었다.
아래는 1000x1000 부터 10000x10000 까지 배열의 element 개수를 변경하면서 numpy/numba(parallel) 의 소요 시간을 측정한 결과이다. 배열의 크기가 커질 수록 소요 시간의 차이가 더욱 심해짐을 확인할 수 있다.
vectorize 는 ufunc 로서 element-by-element fashion 이다. 몇몇의 경우에는 ufunc 만으로는 표현이 어려운 경우가 있다. 예를들어 1개의 밴드(또는 채널)를 가진 이미지를 입력 받고 복수 개의 밴드(또는 채널)를 가진 이미지를 출력하는 함수를 제시해야할 경우이다. gray scale 이미지에 color saturation 작업을 예로들 수 있다. 필요한 밴드 수 만큼의 ufunc 를 생성하고 호출하는 방법으로 구현될 수도 있지만 각각의 ufunc 간 중복된 연산이 존재하면서도 연산 비용이 적지 않을 경우 문제가 될 수 있다. 이 경우 numba.jit 를 이용하면 된다. 하지만 vectorize 를 이용한 방법 보다는 깔끔하지 못하다는 문제가 있다. 성능 및 코드 간결성 비교 numba.vectorize vs numba.jit 에서 그 방법 및 측정된 성능을 제시한다.