파이썬은 일반적으로 C에 비해 100배 느린 것으로 알려져 있습니다. 간단히 설명드리면 동적 타입에 따른 추가 연산 비용이 있습니다. 그리고 컴파일 하지 않는 인터프리터 언어이기 때문에 컴파일 시간은 없다라는 장점은 있지만 최적화된 속도를 얻을 수 없습니다. 이 외에도 부가적인 요소들이 많이 있지만 이 글의 메인 주제가 아니므로 이 정도만 말씀드리고 간단히 넘어가갰습니다.
typedef struct _object {
_PyObject_HEAD_EXTRA
Py_ssize_t ob_refcnt;
PyTypeObject *ob_type;
} PyObject;
typedef struct _typeobject {
PyObject_VAR_HEAD
const char *tp_name; /* For printing, in format "." */
Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */
/* Methods to implement standard operations */
destructor tp_dealloc;
printfunc tp_print;
getattrfunc tp_getattr;
setattrfunc tp_setattr;
......
// Type을 저장하는 Struct는 다음과 같이 꽤나 복잡합니다.
https://github.com/python/cpython 참조
Cython은 타입을 정한 파이썬 코드를 컴파일해 제공하는 방법입니다. 이름에 C가 들어간 것처럼 해당 타입을 정의하는 방법은 C의 형식과 비슷합니다. 그리고 가장 큰 장점은 import를 바로 해서 사용할 수 있다는 자연스러움 입니다. 특히 CPU의 많은 사용이 필요한 코드나 반복 작업이 많은 경우 효과를 잘 볼수 있을 것입니다.
속도 개선 중에 가장 확실한 방법은 컴파일 하는 것입니다. 컴파일을 하면 미리 최적화된 바이너리 코드를 만들기 때문에 인터프리터 언어인 파이썬에서 성능 향상을 기대할 수 있습니다. 파이썬에서는 여러 컴파일 가능한 방법을 제공합니다. 그 중에서도 미리 컴파일을 하는 방식 중 하나인 Cython을 통해 코드를 작성하고 호출하는 방법을 소개하겠습니다. 개요는 다음과 같습니다.
//hello.py
print( "hello world" )
//setup.py
from distutils.core import setup
from Cython.Build import cythonize
setup( ext_modules = cythonize("hello.pyx") )
//cmd command
python setup.py build_ext --inplace
다음과 같이 작성하면 cython관련 pyx파일이 생성이 되고 hello를 import를 할 수 있습니다. import를 하면 바로 print함수가 호출이 되므로 정상적으로 import되었다는 것을 바로 확인할 수 있습니다. 이것이 가장 간단한 형태의 cython 컴파일 방법입니다.
그러면 실제로 간단하면서 CPU를 많이 사용하는 연산을 작성해서 속도 차이가 얼마나 나는지 한번 확인해보겠습니다. 변수를 cdef unsigned long long을 선언해서 64비트의 큰 수를 받을 수 있게 했습니다. 단순 반복 연산을 많이 해서 시간 차이가 많이 나게 했습니다.
//test.pyx
def test_function():
cdef unsigned long long sum = 0
cdef unsigned long long ii = 0
for i in range(100000000):
ii = i
sum += ii
print("result : ", sum)
//test2.py
def test_function():
sum = 0
for i in range(100000000):
sum += i
print("result : ", sum)
import hello
import time
import hello2
if __name__ == "__main__":
start_time = time.time()
hello.test_function()
end_time = time.time()
secs = end_time - start_time
print ("cdef 걸린 시간 : ", secs, "초")
start_time = time.time()
hello2.test_function()
end_time = time.time()
secs = end_time - start_time
print ("파이썬코드 걸린 시간 : ", secs, "초")
result =>
('result : ', 4999999950000000)
cdef 걸린 시간 : 0.016955137252807617 초
result : 4999999950000000
파이썬코드 걸린 시간 : 4.735569715499878 초
cdef를 사용하면 Cython에서 해석되는 변수를 사용할 수 있습니다. 파이썬은 동적 타입 처리 하는 과정에서 느려지기 때문에 타입을 지정하면 성능상의 이점을 얻을 수 있습니다. 하지만 파이썬 스택에서 해당 변수를 호출되지 않기 때문에 유연성은 떨어진다는 단점이 있습니다.
단순한 연산이기 때문에 테스트가 정확하지 않을 수도 있습니다만 꽤 의미 있는 성능 차이가 났습니다. 가장 단순한 컴파일을 통해서 차이가 적었을 것으로 추측되네요. 아마 OPENMP등의 병렬 라이브러리까지 활용하고 컴파일러 특성까지 고려 했다면 더 최적화될 여지가 많아 보이지만 여기는 간단한 소개 이므로 이정도 차이도 의미 있을 것 같습니다. Cython의 특징을 정리 했을 때 다양한 파이썬 코드에서 적용할 수 있고 그 부분에서 일반적인 효율을 가져올 수 있는 방법이라고 생각이 듭니다. 다만 C코드가 들어가기 때문에 더 많은 시간과 프로그래머의 관리가 필요합니다.
파이썬은 CPU처리를 병렬적으로 하기는 문제점이 많습니다. 소위 GIL(Global Interpret Lock)이라는 정책 때문에 강제로 싱글 스레드를 사용하는 효과를 얻을 수 밖에 없습니다. Cython을 사용하면 해당 문제를 해결할 수 있는데 cython.parallel의 prange를 사용하면 해당 결과를 병렬적으로 얻을 수 있습니다. setup.py에서 컴파일 옵션에 openmp를 추가하면 해당 모듈을 사용할 수 있습니다. 그리고 다양한 스레드 스케쥴링 방식을 결정할 수 있어서 상황에 따른 결과를 얻을 수 있습니다.
C의 확장 코드를 사용하지 않는다고 하면 PyPy가 현명한 선택이 될 수 있습니다. PyPy와 Cpython의 가장 큰 차이는 Garbage Collection의 방식에 있습니다. Cpython은 shared_ptr처럼 reference counting을 사용합니다. PyPy는 일반적으로 사용하는 방식인 Mark & Sweep을 사용합니다. 이에 대한 고찰은 Instagram의 개발 이야기를 보면 알 수 있습니다. 다만 PyPy는 GIL의 제약을 여전히 가지고 있으며 일반적으로 RAM 메모리를 더 사용하는 것으로 알려져 있습니다. (참조 : https://doc.pypy.org/en/latest/stm.html)