Cython과 Numba를 사용한 Python 성능 강화
Ethan Miller
Product Engineer · Leapcell

소개
데이터 과학, 인공 지능, 과학 컴퓨팅 분야에서 Python이 차지하는 위상은 부인할 수 없습니다. Python은 가독성, 방대한 라이브러리 생태계, 빠른 개발 주기 덕분에 개발자들에게 사랑받고 있습니다. 하지만 Python은 인터프리터 방식으로 인해 종종 상당한 성능 오버헤드를 동반하며, 특히 복잡한 수치 연산이나 중첩 루프와 같은 계산 집약적인 작업에서 두드러집니다. 이러한 내재된 속도 병목 현상은 심각한 제약이 될 수 있으며, 짧은 시간에 끝나야 할 계산이 답답할 정도로 오래 걸리게 만들 수 있습니다. 머신 러닝 모델 훈련에 몇 분 대신 몇 시간이 걸리거나, 대규모 데이터 세트를 몇 분 대신 몇 초 만에 처리해야 한다고 상상해 보세요. 바로 여기서 성능 최적화 도구의 힘이 매우 중요해집니다. 이 글에서는 Python 개발자가 이러한 성능 장벽을 극복하고, Python의 편리함과 유연성을 활용하면서도 종종 100배 이상의 속도 향상을 달성할 수 있도록 하는 두 가지 강력한 라이브러리인 Cython과 Numba에 대해 자세히 알아봅니다.
Python 가속화를 위한 핵심 개념
Cython과 Numba에 대해 자세히 알아보기 전에, Python 코드 속도를 높이는 데 핵심적인 몇 가지 개념을 이해해 봅시다.
- 전역 인터프리터 잠금 (GIL, Global Interpreter Lock): Python의 GIL은 멀티 코어 프로세서에서도 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 합니다. 이는 CPU 바운드 작업에 주요 병목 현상을 야기하는데, Python 코드의 진정한 병렬 실행이 방해받기 때문입니다.
- 동적 타이핑 (Dynamic Typing): Python의 변수는 동적 타이핑됩니다. 즉, 변수의 타입은 런타임에 결정됩니다. 이는 유연성을 제공하지만, 인터프리터가 지속적으로 타입을 확인하고 다시 확인해야 하므로 런타임 오버헤드가 발생하며, 정적 타이핑 언어 컴파일러가 수행할 수 있는 공격적인 최적화를 방지합니다.
- 인터프리터 방식 vs. 컴파일 방식: Python은 인터프리터 언어입니다. 즉, 코드는 인터프리터에 의해 줄 단위로 실행됩니다. 반대로 컴파일 언어는 실행 전에 전체 소스 코드를 기계가 읽을 수 있는 명령어(기계어)로 변환합니다. 컴파일된 코드는 일반적으로 훨씬 빠르게 실행됩니다.
- Just-In-Time (JIT) 컴파일: JIT 컴파일러는 런타임 중에 코드가 실행될 때 코드를 기계어로 번역합니다. 이는 종종 자주 실행되는 "핫" 코드 경로를 컴파일함으로써 인터프리터의 유연성과 컴파일의 성능 이점을 결합합니다.
- 정적 타이핑 (Cython에서): Python은 동적 타이핑이지만, Cython은 Python 코드에 정적 타입 선언을 추가할 수 있게 해줍니다. 이는 컴파일러에 중요한 정보를 제공하여 더 최적화된 기계 코드를 생성할 수 있게 합니다.
Cython: Python과 C의 연결
Cython은 Python 언어의 슈퍼셋으로, Python과 직접 상호 작용하는 C와 유사한 코드를 작성할 수 있게 해줍니다. 주요 목표는 대부분 Pythonic 코드를 작성하면서 C 수준의 성능을 제공하는 것입니다. Cython 코드는 C 코드로 컴파일되고, 이는 다시 기계 코드로 컴파일된 후 Python 모듈로 래핑됩니다. 이 프로세스는 최적화된 섹션에 대해 Python 인터프리터를 우회하여 상당한 성능 향상을 이끌어냅니다.
Cython 작동 방식
.pyx
파일 작성:cdef
(C로 정의),cpdef
(C
및Python
으로 정의),def
(Python으로 정의) 타입 선언을 선택적으로 추가하여 Python 코드를 작성합니다.- Cython 컴파일:
pyx
파일은 Cython 컴파일러에 의해.c
파일로 변환됩니다. - C 컴파일: 표준 C 컴파일러(예: GCC)가
.c
파일을 공유 라이브러리(예: Linux의.so
, Windows의.pyd
)로 컴파일합니다. - 가져와서 사용: 이 공유 라이브러리는 다른 Python 모듈과 마찬가지로 Python 스크립트에서 직접 가져와 사용할 수 있습니다.
실질적인 예시: 제곱의 합
계산 집약적인 단순한 작업, 즉 큰 정수까지의 숫자의 제곱 합계를 고려해 봅시다.
순수 Python:
# pure_python.py import time def sum_squares_python(n): total = 0 for i in range(n): total += i * i return total if __name__ == '__main__': N = 100_000_000 start_time = time.time() result = sum_squares_python(N) end_time = time.time() print(f"Python result: {result}") print(f"Python execution time: {end_time - start_time:.4f} seconds")
Cython 구현:
먼저 sum_squares_cython.pyx
파일을 만듭니다.
# sum_squares_cython.pyx def sum_squares_cython(int n): # n을 정수로 선언 cdef long long total = 0 # total을 C long long으로 선언 cdef int i # 루프 변수 i를 C 정수로 선언 for i in range(n): total += i * i return total
다음으로 Cython 코드를 컴파일하기 위한 setup.py
파일을 만듭니다.
# setup.py from setuptools import setup from Cython.Build import cythonize setup( ext_modules = cythonize("sum_squares_cython.pyx") )
컴파일하려면 터미널에서 python setup.py build_ext --inplace
를 실행합니다. 그러면 컴파일된 모듈이 생성됩니다.
이제 Python 스크립트에서 사용할 수 있습니다.
# test_cython.py import time # sum_squares_python은 pure_python.py에 있다고 가정 from pure_python import sum_squares_python import sum_squares_cython # 컴파일된 Cython 모듈 가져오기 if __name__ == '__main__': N = 100_000_000 # 순수 Python print("---"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"---") start_time = time.time() result_py = sum_squares_python(N) end_time = time.time() print(f"Python result: {result_py}") print(f"Python execution time: {end_time - start_time:.4f} seconds\n") # Cython print("---"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"---") start_time = time.time() result_cy = sum_squares_cython.sum_squares_cython(N) end_time = time.time() print(f"Cython result: {result_cy}") print(f"Cython execution time: {end_time - start_time:.4f} seconds")
일반적인 시스템에서는 극적인 속도 향상을 관찰할 수 있습니다. N = 100,000,000
의 경우, Python 버전은 3-5초가 걸릴 수 있지만 Cython 버전은 0.1초 미만으로 완료되어 시스템 및 Python 버전에 따라 30배에서 50배 또는 그 이상의 속도 향상을 달성할 수 있습니다.
Cython의 주요 장점:
- 세밀한 제어: 메모리와 타입에 대한 탁월한 제어를 제공하여 매우 최적화된 코드를 작성할 수 있습니다.
- C/C++와의 통합: 기존 C/C++ 라이브러리와 쉽게 통합됩니다.
- C로 컴파일: 매우 성능이 뛰어난 컴파일된 코드를 생성합니다.
- 하위 호환성: 기존 Python 코드는 종종 Cython 타입 힌트를 사용하여 점진적으로 최적화될 수 있습니다.
Cython의 적용 시나리오:
- Python과 C/C++ 라이브러리 통합: C/C++ 코드를 Python에서 사용하기 위해 래핑할 때.
- 수치 알고리즘: 타이트한 루프와 수학적 계산을 가속화합니다.
- 고성능 컴퓨팅 (HPC): 모든 밀리초가 중요할 때.
- Python 확장: 빠르고 컴파일된 모듈을 Python에 대해 생성합니다.
Numba: 수치 Python을 위한 JIT 컴파일
Numba는 LLVM 컴파일러 인프라를 사용하여 Python 함수를 런타임에 최적화된 기계 코드로 변환하는 오픈 소스 JIT 컴파일러입니다. 특히 NumPy 배열을 포함하는 수치 알고리즘에 매우 적합합니다. Cython과 달리 사전 컴파일 단계와 런타임 이전에 명시적인 타입 선언이 필요한 것과 달리, Numba는 함수가 처음 호출될 때 자동으로 타입을 추론하고 함수를 즉석에서 컴파일합니다. 이 "즉석" 컴파일은 종종 데코레이터 추가만으로도 최소한의 코드 변경으로 상당한 속도 향상을 달성할 수 있음을 의미합니다.
Numba 작동 방식
- 데코레이터 추가:
@numba.jit
(또는 최고의 성능을 위해 nopython 모드의 경우@numby.njit
)로 Python 함수를 데코레이션합니다. - 첫 번째 호출: 데코레이션된 함수가 처음 호출될 때 Numba는 Python 바이트코드를 분석하고, 변수 타입을 추론하고, 해당 함수에 대한 최적화된 기계 코드를 생성합니다.
- 실행: 이후 함수의 호출은 컴파일된 기계 코드를 사용하여 훨씬 더 빠르게 실행됩니다.
실질적인 예시: Numba를 사용한 제곱 합계
제곱 합계 예시를 다시 살펴봅시다.
# numba_example.py import time import numba # 비교를 위해 sum_squares_python은 pure_python.py에 있다고 가정 from pure_python import sum_squares_python @numba.njit # 최고의 성능을 위해 nopython 모드 사용 def sum_squares_numba(n): total = 0 for i in range(n): total += i * i return total if __name__ == '__main__': N = 100_000_000 # 순수 Python print("---"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"---") start_time = time.time() result_py = sum_squares_python(N) end_time = time.time() print(f"Python result: {result_py}") print(f"Python execution time: {end_time - start_time:.4f} seconds\n") # Numba print("---"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"-"---") # 첫 번째 호출이 함수를 컴파일합니다 (오버헤드 추가) _ = sum_squares_numba(1) # 워밍업 호출 start_time = time.time() result_nb = sum_squares_numba(N) end_time = time.time() print(f"Numba result: {result_nb}") print(f"Numba execution time: {end_time - start_time:.4f} seconds")
마찬가지로 Numba를 사용하면 N = 100,000,000
에 대해 상당한 속도 향상을 관찰할 수 있으며, 종종 이 특정 유형의 수치 루프에 대해 Cython과 비슷하거나 그 이상의 성능을 발휘하며, 다시 30배에서 100배 또는 그 이상의 속도 향상을 달성합니다. Numba의 장점은 코드 수정이 얼마나 적게 필요했는지입니다.
Numba의 주요 장점:
- 최소한의 코드 변경: 종종
@jit
또는@njit
데코레이터만 추가하면 됩니다. - 자동 타입 추론: 명시적인 타입 선언이 필요 없습니다.
- 런타임 컴파일 (JIT): 즉석에서 코드를 컴파일하여 즉시 사용할 수 있습니다.
- NumPy에 탁월: NumPy 배열에 대한 작업에 매우 최적화되어 있습니다.
- CUDA 지원: NVIDIA GPU용 Python 코드를 컴파일하는 간단한 방법을 제공합니다.
Numba의 적용 시나리오:
- 수치 및 과학 컴퓨팅: 배열 작업, 시뮬레이션 및 데이터 처리를 가속화합니다.
- 기계 학습 (맞춤형 알고리즘): 맞춤형 손실 함수, 활성화 함수 또는 기울기 계산을 가속화합니다.
- 모든 CPU 바운드 루프: Python의 인터프리터 오버헤드가 병목 현상인 경우.
- GPU 프로그래밍: 최소한의 노력으로 CUDA 코어를 활용합니다.
결론
Cython과 Numba는 모두 Python의 성능 제한을 극복하는 탁월한 도구이며, 각각 고유한 강점과 사용 사례를 가지고 있습니다. Cython은 세밀한 제어와 원활한 C/C++ 통합을 제공하여 깊이 임베딩된 고성능 모듈에 이상적인 반면, Numba는 최소한의 변경으로 수치 코드에 상당한 속도 향상을 제공하는 매우 사용하기 쉬운 JIT 컴파일 접근 방식을 제공합니다. 이러한 강력한 라이브러리를 전략적으로 적용함으로써 Python 개발자는 진정으로 놀라운 성능 향상을 달성하고, 종종 느리고 병목 현상이 있는 스크립트를 수백 배 더 빠르게 실행되는 초고속 애플리케이션으로 변환할 수 있습니다. Python의 유명한 사용 편의성과 유연성을 희생하지 않으면서도 Python이 컴파일 언어 수준에서 성능을 경쟁할 수 있도록 역량을 부여합니다.