컴파일링
최적의 알고리즘으로 처리할 데이터를 간소화했다면, 수행할 명령을 줄이는 방법으로 기계어로 컴파일하는 방법을 사용할 수 있다.
아래의 사진처럼 속도를 개선하는 작업을 할 때는 어느 정도 정해진 순서가 있다.
먼저 프로파일링을 통해 코드가 어떻게 동작하고, 어느 곳에서 병목이 일어나는지 이해하면 알고리즘 수준에서 속도를 향상시킬 수 있다. 하지만 개선을 진행할수록 알고리즘으로 할 수 있는 성능의 개선폭은 작아지므로 일정 수준 진행되었으면 컴파일을 통해 추가로 성능을 높일 수 있다.
컴파일링으로 성능을 높일 수 있는 이유
파이썬은 동적 타입 언어이기 때문에 항상 함수의 인자를 사용하기 전 해당 인자의 타입을 살펴보고 판단한다. 이런 호출 횟수가 많을 수록 오버헤드는 증가하기에 파이썬이 느리게 동작하는 것이다.
고로 변수의 타입을 잘 바꾸지 않는 작업을 할때는 변수를 정적으로 컴파일링을 함으로써 성능을 높일 수 있다.
또한 고수준의 파이썬 객체가 아닌 저수준의 C 객체로 변환해 연산을 함으로써 성능을 높일 수도 있다.
컴파일러의 종류
컴파일러에는 AOT, JIT 컴파일러가 있다.
AOT(Ahead Of Time)는 사이썬이 사용하는 미리 컴파일하는 방식이다. numpy, scipy, 사이키런을 설치하면 이들은 사이썬을 사용해서 라이브러리의 일부를 설치하는 컴퓨터에 맞게 미리 컴파일한다.
JIT(Just In Time)은 Numba, PyPy에서 사용하는 적절한 때에 컴파일하는 방식이다.
이는 미리 컴파일하지 않고 실행 후에 컴파일을 한 후 작업을 하게 하므로 ‘콜드 스타트’ 문제가 있다.
고로 짧지만 자주 실행하는 스크립트에는 사용하지 않는 편이 좋을 수 있다.
추가로, AOT를 이용하는게 최선이지만 직접적인 노력이 많이 들고, JIT은 직접적인 노력없이 속도를 상당히 개선할 수 있지만 ‘콜드 스타트’와 같은 문제가 있다.
사이썬
사이썬은 타입 어노테이션을 통해 타입을 명시한 파이썬 코드를 컴파일된 확장 모듈로 변경해주는 컴파일러이다.
(확장 모듈은 import로 일반적인 파이썬 모듈처럼 사용할 수 있다.)
또한 OpenMP와 사이썬을 같이 사용하면 병렬처리 문제를 고려한 모듈로 변경할 수 있다.
사이썬을 사용하는 라이브러리는 Scipy, 사이킷런, lxml, ZeroMQ 등이 있다.
사용법
사이썬으로 파이썬 코드를 컴파일하기 위해서는 2개의 파일이 필요하다.
- 성능 개선을 하고 싶은 파이썬 함수를 저장한 .pyx 파일
- 확장 모듈을 작성하기 위해 사이썬을 호출하는 과정이 있는 setup.py 파일
즉, setup.py 스크립트에서 사이썬을 사용해서 .pyx 파일을 .so(유닉스 계열) 또는 .pyd(윈도우) 파일로 컴파일한다.
# 컴파일하고 싶은 코드가 들어있는 cythonfn.pyx 파일
def function():
return 0
# 사이썬을 이용해 cythonfn.pyx를 컴파일된 C코드로 변경하는 setup.py
from distutils.core import setup
from Cython.Build import cythonize
setup(ext_modules=cythonize("cythonfn.pyx", compiler_directives={"language_level": "3"}))
위의 두 파일을 만들고 build_ext 명령을 통해 cythonfn.pyx 파일을 cythonfn.so 파일로 빌드한다.
(__inplace는 컴파일된 모듈을 별도 디렉토리가 아니라 현재 디렉토리에 생성하도록 하는 옵션이다.)
python setup.py build_ext __inplace
이후 import문을 사용해 컴파일된 함수를 가져와 사용할 수 있다.
import cythonfn
cythonfn.function()
pyximport
pyximport를 사용하면 따로 컴파일하는 과정없이 실행시 컴파일과 실행을 둘다 할 수 있게 해준다.
고로 setup.py가 필요하지 않고, 훨씬 간편하게 컴파일된 함수를 사용할 수 있다. 성능도 동일하다.
import pyximport
pyximport.install(language_level=3)
import cythonfn
cythonfn.function()
pyximport를 통해 컴파일된 모듈을 빠르게 만들어 사용할 수 있지만, 맹목적으로 최적화하면 안된다.
프로파일링을 통해 어느 부분에 병목이 생기는 지 파악하고 그 부분에 노력을 집중해야 한다.
코드 블록 분석
cython -a cythonfn.pyx
위 코드를 실행하면 cythonfn.pyx 내에 있는 함수의 코드를 줄별로 분석해서 HTML 파일을 생성한다.
해당 줄의 색이 짙을수록 파이썬 가상머신에서 더 많은 호출이 발생함을 나타내고, 옅을 수록 파이썬보다 C코드가 더 많다는 의미이다.
또한 줄을 클릭하면 파이썬 코드에 대응하는 C코드를 볼 수 있다.
생성한 HTML 파일
(이해를 돕기 위해 예제 코드를 사용한다. 어떤 코드인지는 중요하지 않다.)
분석된 결과를 보면 함수의 거의 모든 줄이 파이썬 가상 머신에서 실행되고, 산술 연산 역시 파이썬 객체를 사용하므로 파이썬 가상 머신에서 실행된다.
이 부분을 C 객체로 변환하여 계산한 후, 결과를 다시 파이썬 객체로 변환해야한다.
타입 어노테이션 추가
위의 사이썬, pyimport의 사용법을 다룰 때는 타입 어노테이션을 사용하지 않았기 때문에 단순히 변수를 동적에서 정적으로 할당함으로써 성능을 높인 것이다.
이번에는 타입 어노테이션을 사용해 파이썬 객체를 C 객체로 변환시키는 방식으로 성능을 높인다.
위 코드 블록 분석에서 사용한 예제 코드에서 타입 어노테이션을 사용해 객체의 타입을 바꾸는 부분만 추가한 후 다시 코드를 분석해보자.
def calculate_z(int maxiter, zs, cd):
cdef unsigned int i, n
cdef double complex z, c
...
여기서 cdef 문법은 파이썬 문법이 아니지만 사이썬에서 사용하는 문법이다.
cdef를 통해 변수 i, n, z, c를 파이썬 객체에서 C객체로 변환했다.
타입 어노테이션 추가 후 생성한 HTML 파일
사이썬과 넘파이
리스트의 원소들은 메모리 어디든 존재할 수 있어서 역참조에 비용이 드는 반면, array 객체는 연속적인 RAM 블록에 저장하므로 주소 계산이 빠르다.
고로 array 객체를 사용하면 쉽게 메모리 주소를 계산해 접근하도록 컴파일러에게 지시할 수 있다.
memoryview
memoryview를 통해 버퍼 인터페이스를 구현하고 연속적인 메모리가 할당되는 모든 객체에 같은 방식의 저수준 접근을 할 수 있다.
또한 파이썬 객체를 다른 형태로 변환하지 않고도 메모리를 다른 C 라이브러리와 쉽게 공유할 수 있다.
# cythonfn.pyx
import numpy as np
cimport numpy as np
def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs):
"""Calculate output list using Julia update rule"""
cdef unsigned int i, n
cdef double complex z, c
cdef int[:] output = np.empty(len(zs), dtype=np.int32)
...
- 함수의 두번째 인자는 double complex[:] zs로, 1차원 데이터 블록이 있는 (하나의 콜론(:)으로 표시) 버퍼 프로토콜([]로 표시)을 사용하는 배정밀도 복소수 객체라는 뜻이다.
- zs는 c코드 수준으로 정의되어서 zs[i]와 같은 역참조시 순수 파이썬 리스트보다 빨라졌다.
- np.empty를 통해 1차원 numpy array를 할당해서 output 변수에도 어노테이션을 달았다.
OpenMP를 사용한 병렬화
OpenMP(Open Multi-Processing)는 C, C++, 포트란에서 병렬 실행과 메모리 공유를 지원하는 다중 플랫폼 API이다.
이는 적절히 작성된 C코드가 있으면 컴파일러 수준에서 병렬화해준다.
→ 즉, 사이썬을 사용해 상대적으로 적은 노력으로 병렬화를 누릴 수 있다.
with nogil은 GIL을 비활성화할 블록을 지정한다. 그 블록안에서 prange를 사용해 OpenMP의 병렬 for 루프가 각 i를 병렬적으로 계산하게 한다. (❗️GIL을 비활성화한 상태에서는 오직 기본 객체들과 memoryview 인터페이스를 지원하는 객체에 대해서만 수행해야 한다. GIL이 해결해주는 연관 메모리 관리 문제를 직접 해결하기 어렵기 때문에 리스트같은 일반적인 파이썬 객체에 대한 연산을 수행하지 마라.)
GIL을 비활성화했으므로 병렬 범위 연산자인 prange는 prange 상에서 루프를 병렬로 수행할 수 있다.
또한 prange는 스케줄링 방식을 선택할 수 있다. (작업의 특성에 따라 선택한다.)
- static : 사용가능한 CPU에 부하를 균등하게 분산한다. 일부 계산은 비용이 많이 들고 일부는 적게 드므로 다른 CPU보다 작업이 빨리 끝난 CPU가 생기고 해당 스레드는 유휴 상태로 남는다.
- dynamic과 guided : 실행 시점에 부하를 더 작은 덩어리로 동적으로 분배한다. 부하를 계산하는 시간이 가변적이지만 CPU를 더 균등하게 분배하므로 유휴 상태에 머무는 스레드가 줄어든다.
# cythonfn.pyx
from cython.parallel import prange
import numpy as np
cimport numpy as np
def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs):
"""Calculate output list using Julia update rule"""
cdef unsigned int i, length
cdef double complex z, c
cdef int[:] output = np.empty(len(zs), dtype=np.int32)
length = len(zs)
with nogil:
for i in prange(length, schedule="guided"):
z = zs[i]
c = cs[i]
output[i] = 0
while output[i] < maxiter and (z.real * z.real + z.imag * z.imag) < 4:
z = z * z + c
output[i] += 1
return output
#setup.py
from distutils.core import setup
from distutils.extension import Extension
import numpy as np
ext_modules = [Extension("cythonfn",
["cythonfn.pyx"],
extra_compile_args=['-Xclang -fopenmp'],
extra_link_args=['-Xclang -fopenmp'])]
from Cython.Build import cythonize
setup(ext_modules=cythonize(ext_modules,
compiler_directives={"language_level": "3"},),
include_dirs=[np.get_include()])
setup.py에 -fopenmp 컴파일러 지시자를 넣어서 OpenMP를 활성화하고, OpenMP 라이브러리와 링크하도록 스크립트에 명시한다.
'Python' 카테고리의 다른 글
[고성능 파이썬] 6.2 행렬과 벡터 연산 (0) | 2023.02.13 |
---|---|
[고성능 파이썬] 6.1 행렬과 벡터연산 (0) | 2023.02.10 |
[고성능 파이썬] 10.3 (추가) NSQ와 GCP를 이용한 원격 클러스터링 (0) | 2023.02.08 |
[고성능 파이썬] 10.2 클러스터와 작업 큐 - NSQ와 도커 (0) | 2023.02.07 |
[고성능 파이썬] 10.1 클러스터와 작업큐 (0) | 2023.02.06 |