본문 바로가기

Computer/Python

행렬과 벡터 연산 (6) - numexpr 모듈 이용하기

반응형

행렬과 벡터 연산 (1) - 확산 방정식 예제

행렬과 벡터 연산 (2) - 확산 방정식 순수 파이썬 구현

행렬과 벡터 연산 (3) - 파이썬 리스트와 numpy 연산 속도 차이

행렬과 벡터 연산 (4) - numpy 배열을 이용한 확산 방정식

행렬과 벡터 연산 (5) - numpy 배열 메모리 최적화


지난 포스트를 거치면서 1) 메모리의 지역성, CPU 벡터 연산 활용을 지원하는 numpy 배열을 이용하고 2) 제자리 연산, numpy 함수 개량 등을 통해 확산 방정식의 속도를 크게 개선했습니다. 제자리 연산을 통해 메모리 재활용을 할 수 있어 캐시 미스를 줄일 수 있었지만 제자리 연산은 한 번에 하나의 연산에만 적용할 수 있는 단점이 있습니다. 예를 들어 A*B+C를 계산한다고 했을 때, 먼저 A*B 계산 결과를 임시 벡터에 저장한 다음에 C를 더해야 하는거죠. 이때 numexpr 모듈을 활용할 수 있습니다.

numexpr 모듈은 계산에 사용한 전체 벡터를 취합해서 캐시 미스와 임시 공간 사용을 최소화하는 효율적인 코드로 컴파일합니다. 또한, 코어의 연산을 병렬화해주는 멀티 코어 최적화도 지원합니다. 사용 방법은 파이썬의 eval 내장 함수처럼 문자열 표현으로 지역 변수를 참조하면 됩니다. (numexpr 모듈은 pip를 이용해 설치해주어야 합니다) 이 문자열 표현은 내부적으로 컴파일된 뒤 캐시에 저장되어 두 번 이상 컴파일되지 않으면서 evaluate 함수의 out 인자를 사용하여 numexpr이 계산 결과를 반환하려고 새로운 벡터를 할당하지 않습니다.

from numexpr import evaluate

def evolve(grid, dt, out, D=1):
    laplacian(grid, out)
    evaluate("out * D * dt + grid", out=out)

numexpr의 중요한 특징은 문자열 표현을 컴파일하여 캐시에 저장하고 이 캐시에 데이터가 올바르게 저장되도록 하여 캐시 미스를 최소화한다는 점입니다. 그렇다면 실제 실행 시간은 어떻게 변할까요? 다음 표는 격자 크기에 따른 기존 numpy와 numexpr evalute 함수 사용 시의 실행 시간을 나타냅니다. 결과를 보면 격자 크기가 1280 미만일 때는 기존 numpy가 더 빠르고 1280 이상 더 커질 때야 numexpr 사용의 효과가 나타납니다. 왜 그럴까요?

격자 크기 기존 numpy numexpr 사용 시
(320, 320) 0.162 s 0.248 s
(640, 640) 0.831 s 1.084 s
(1280, 1280) 4.756 s 4.759 s
(2560, 2560) 19.817 s 18.589 s

numexpr을 사용함으로써 프로그램에서 캐시를 잘 활용하기 위한 연산이 추가됩니다. 다라서 계산에 필요한 모든 데이터가 캐시에 저장될 만큼 격자가 작다면 numexpr을 사용해도 연산이 추가되기만 할 뿐 성능에는 도움이 되지 않겠죠. 또한 문자열로 표현한 연산을 컴파일하는데 많은 오버헤드가 발생합니다. 실제로 캐시가 8MB= 8192KB 라고 가정했을 때, 캐시에 담을 수 있는 격자 항목의 최대 개수는 8192KB/64bit=1,024,000 개이고 총 2개의 격자를 사용하므로 512,000개가 됩니다. 이것의 제곱근을 구하면 대략 700 정도가 나오는데, 이 크기까지는 numexpr 사용의 이점을 잘 얻을 수 없겠죠. 하지만 격자가 캐시 크기보다 훨씬 크다면 numexpr이 캐시를 더 잘 활용하고 멀티 코어의 각 코어 별 캐시를 최대한 활용하기에 추가적인 오버헤드보다 캐시 활용으로 인한 성능 향상이 더 크게 됩니다.

반응형