알아두면 좋은 파이썬 개념 - 4. 속도의 문제 (Pypy, Numba, Cython & Multiprocessing, Asyncio)

7 minute read

‘알아두면 좋은 파이썬 개념’ 시리즈의 네번째 글로 파이썬의 고질적인 속도 문제를 해결하기 위한 방법들에 대해 알아보겠다.

속도의 문제 해결 방법 1: Pypy, Numba, Cython

파이썬을 사용하다보면 필연적으로 속도의 문제에 부딪힌다. 파이썬에서는 이를 해결하기 위한 파이썬만의 몇 가지 방법을 제공한다.

Pypy

가장 구현이 편리한 방식이다. 기본 파이썬 인터프리터를 사용하지 않고 pypy3 JIT Complier를 이용해 코드를 해석시켜 속도를 향상시킨다. 루프문을 많이 쓴 코드는 일반 파이썬으로 돌릴 때보다 10배 이상의 속도 차이를 보여준다.

공식 홈페이지인 https://www.pypy.org/download.html 를 통해 원하는 버전을 다운 받거나 mac os라면 brew install pypy3 로 쉽게 설치가 가능하다. 만약 카탈리나 os를 사용한다면 공식 페이지에서 다운 받은 버전을 사용할 때 약간의 버그가 있어 brew를 사용한 방법을 추천한다.

사용법은 간단하다. 실행 코드를 python sample.py 에서 pypy3 sample.py 로 변경하기만 하면 대부분의 라이브러리에 대해 코드 변경 없이 사용할 수 있다. Virtualenv 등을 활용해 특정 파이썬 버전처럼 가상환경 구성도 가능하며 pip을 통해 원하는 라이브러리를 설치해 사용 가능하다.

단점으로 c를 기반으로 구현된 일부 라이브러리 호환성과 특정 라이브러리 버그가 있다. 하지만 라이브러리 지원도 점점 확대되고 버그도 많이 수정되어 사용성이 점차 개선되고 있는 상황이다.

다만, 데이터 분석용 코드를 Pypy로 바꿀 때 데이터 분석에서 자주 쓰이는 아래 라이브러리들을 지원하지 않으므로 해당 라이브러리를 사용한다면 다른 방법의 속도 향상을 고려해야 한다.

  • pypy 지원 불가 라이브러리: pandas, sklearn, matplotlib, tensorflow 등

Numba

Pypy와 마찬가지로 JIT Compiler를 이용해 속도를 향상시킨다. numpy 지원이 강력해 머신러닝 등 분석 코드에서 많이 사용된다. Pypy와 달리 사용을 위해 코드의 수정이 필요하지만 기본적으로 @jit 라는 데코레이터만 함수 위에 써주면 작동하므로 쉽게 사용 가능하다.

pip install numba 를 통해 라이브러리 설치가 필요하다.

아래 예시는 Numba 공식 홈페이지의 “A ~5 minute guide to Numba”의 예시 코드이다.

numba_example.py

from numba import jit
import numpy as np
import time

@jit(nopython=True)
def go_fast(a):
    trace = 0.0
    for i in range(a.shape[0]):
        trace += np.tanh(a[i, i])
    return a + trace

if __name__ == "__main__":
    x = np.arange(100).reshape(10, 10)

    # compile
    go_fast(x)

    # actual
    start = time.time()
    go_fast(x)
    end = time.time()
    print("걸린 시간 = %s" % (end - start))

go_fast() 함수 위에 @jit(nopython=True) 를 제외하면 사실상 일반 파이썬 코드와 다를 게 없다. 하지만, jit 데코레이터를 붙여줌으로써 해당 함수를 처음으로 실행할 때 최적화된 방식으로 코드를 컴파일해 속도를 향상시킨다. jit 컴파일러는 nopython 모드와 파이썬 객체와 호환이 가능한 object 모드로 컴파일이 가능한데, nopython=True 를 붙여주게 되면 무조건적으로 nopython 모드로 컴파일이 된다. 이렇게 되면 파이썬과 잘 호환되지는 않지만 c에 가까운 형식으로 코드를 컴파일해 object 모드에 비해 훨씬 빠른 속도 향상을 기대할 수 있다.

다만, Numba는 완전 만능이 아니다. jit 데코레이터를 붙여서 Numba가 작동하려면 함수 내부적으로 사용하는 데이터 타입을 신경써야 한다. 위 예시에서 trace = 0.0 부분을 trace = 0 으로 바꾸면 파이썬 코드나 object 모드의 Numba 컴파일에서는 문제 없이 작동하지만 nopython 모드에서는 더하는 값과 형이 맞지 않아 컴파일이 되지 않는다. 또한, 새로운 클래스들을 만들어서 작성해놓은 기존 코드 위에 @jit 를 붙여 작동을 기대한다면 많은 에러를 보게 될 것이다. Numba 사용을 위해서는 Numba가 이해할 수 있는 데이터 타입을 바탕으로 데이터 타입 간의 연산을 고려해 코드를 작성하는 것이 필요하다.

위 내용만 보면 사용이 쉽지 않아보이지만 처음부터 Numba 사용을 고려하고 numpy나 Numba에서 호환되는 데이터 타입 위주로 코드가 구성되어 있다면 큰 문제 없이 사용할 수 있다.

Cython

Cython은 Pypy나 Numba와 달리 아예 파이썬 코드를 컴파일해 import 할 수 있는 C 확장 라이브러리를 만들어 속도를 향상시킨다. 위 두 방법에 비해 복잡하지만 좀 더 근본적인 접근 방법이라 볼 수 있다. .pyx 파일을 파이썬 코드를 작성하고 setuptools의 setup을 통해 .so 파일이나 .pyd 파일로 컴파일하고 import 해서 파이썬에서 사용한다.

pip install cython 을 통해 라이브러리 설치가 필요하다.

아래 코드들은 Cython을 통해 helloworld.pyxcalc.pyx 모듈을 확장 C 라이브러리로 만든 예시이다.

helloworld.pyx

print('helloworld')

calc.pyx

def cy_cdef_complex_calc():
    cdef unsigned long long result = 0
    cdef unsigned long long ii = 0
    for i in range(100000000):
        ii = i
        result += 2*ii + 3
    return result

def cy_complex_calc():
    result = 0
    for i in range(100000000):
        result += 2*i + 3
    return result

먼저 .pyx 파일 작성이 필요하다. helloworld.pyx는 일반적인 코드 테스트 용도로 작성했다. calc.pyx를 보면 같은 기능을 하는 함수를 두 가지 방식으로 만든 것을 볼 수 있다. 첫번째는 cdef를 활용해 정적인 형 지정을 해줬다. 파이썬의 경우 동적 타입 체크를 하는 과정에서 속도가 저하되는데 cdef를 활용하면 이런 시간을 줄여줘 추가적인 속도 향상이 가능하다. 두번째 함수는 cdef 없이 일반적인 파이썬 코드를 작성했다. 두번째의 경우에도 Cython을 통해 컴파일하게 되면 최적화가 추가되어 일반 파이썬 코드보다는 빠르게 작동하게 된다.

setup.py

from setuptools import setup
from distutils.extension import Extension
from Cython.Build import cythonize
from Cython.Distutils import build_ext

# 1. 특정 경로에 있는 모든 파이썬 파일 cythonize
setup(
    ext_modules=cythonize("cmodule/*.pyx")
)

# 2. Extension instances 직접 이용
# ext_modules = [
#     Extension("cmodule.helloworld",
#               ["cmodule/helloworld.pyx"]),
#     Extension("cmodule.calc",
#               ["cmodule/calc.pyx"])
# ]

# setup(
#     cmdclass={"build_ext": build_ext},
#     ext_modules=ext_modules
# )

작성한 .pyx 파일은 setup.py 모듈에 작성된 방식으로 컴파일된다. 1번으로 표시된 방식으로 특정 경로에 있는 파이썬 파일을 모두 컴파일 할 수도 있고 2번으로 표시된 것처럼 원하는 모듈들을 지정해 컴파일 할 수 있다.

setup.py를 이용한 컴파일

$ python setup.py build_ext --inplace
.
├── build
│   ├── lib.macosx-10.9-x86_64-3.7
│   └── temp.macosx-10.9-x86_64-3.7
├── cmodule
│   ├── __init__.py
│   ├── __pycache__
│   ├── calc.c
│   ├── calc.cpython-37m-darwin.so
│   ├── calc.pyx
│   ├── helloworld.c
│   ├── helloworld.cpython-37m-darwin.so
│   └── helloworld.pyx
├── main.py
├── requirements.txt
├── setup.py
└── tutorial-venv
    ├── bin
    ├── include
    ├── lib
    └── pyvenv.cfg

setup.py 모듈을 바탕으로 터미널에서 python setup.py build_ext --inplace 코드를 실행하게 되면 위와 같은 디렉토리 구조가 된다. Cython은 .pyx 파일을 .c 파일과 .so 파일로 만드는데 실제적으로 .so 파일을 통해 c로 만들어진 라이브러리를 불러오게 된다.

main.py

import cmodule.helloworld
import cmodule.calc as calc
import timeit

def py_complex_calc():
    result = 0
    for i in range(100000000):
        result += 2*i + 3
    return result

if __name__ == '__main__':
		# complex calc with cdef
    start = timeit.default_timer()
    print("result: ", calc.cy_cdef_complex_calc())
    end = timeit.default_timer()
    print("time: ", (end-start))
    print('')

    # complex calc without cdef
    start = timeit.default_timer()
    print("result: ", calc.cy_complex_calc())
    end = timeit.default_timer()
    print("time: ", (end - start))
    print('')

    # complex calc with pure python
    start = timeit.default_timer()
    print("result: ", py_complex_calc())
    end = timeit.default_timer()
    print("time: ", (end - start))
    print('')

main 모듈에서 만들어진 C 확장 라이브러리들은 위처럼 사용될 수 있다. .pyx 파일을 작성하고 setup을 통해 .so 파일만 만들어지면 일반 파이썬 모듈을 쓰는 것처럼 코드를 불러와 일반 파이썬보다 빠르게 작동하는 함수를 사용할 수 있다.

조금 어렵긴하지만 특정 로직에 시간을 줄이고 싶을 때 충분히 고려해 사용해볼만한 옵션이다.

속도의 문제 해결 방법 2: Multiprocessing, Multithreading, 비동기

파이썬도 다른 언어처럼 multithread, multiprocess, 비동기 방식을 이용해 프로그램 속도를 개선할 수 있다.

Multiprocessing vs Multithreading

Multiprocessing과 Multithreading은 계산량이 많은 문제에서 순차적으로 코드를 돌리는 것보다 동시에 코드를 실행시켜 시간을 단축시킬 수 있을 때 사용된다.

다만 파이썬의 경우 GIL(Global Interpreter Lock) 때문에 process 내의 thread가 동시간에 하나씩 작동되도록 설계되어 있어 Multithreading을 이용이 굉장히 불리하다.

따라서 파이썬에서 동시성을 구현할 때는 thread가 아닌 process를 이용하는 Multiprocessing이 주로 활용된다.

아래 코드는 간단한 Multiprocessing 활용 예시이다.

multiprocessing_example.py

from multiprocessing import Pool

def f(x):
    return x*x

if __name__ == '__main__':
    with Pool(5) as p:
        print(p.map(f, [1, 2, 3]))

Multiprocessing의 Pool을 활용해 5개의 프로세스가 만들어진다. Process들은 각각 1, 2, 3을 인자로 받아 f() 함수를 실행시키고 최종적으로 프로세스의 실행 결과가 합쳐져 출력된다.

비동기 - Asyncio

비동기 방식의 프로그래밍은 네트워크 I/O 등에서 발생하는 대기 시간을 해결하기 위해 주로 사용된다. API를 통해 데이터를 요청하거나 DB에서 데이터를 조회할 때 결과를 받을 때까지 대기시간이 생기게 된다. 비동기 방식은 이럴 때 요청을 먼저 보내놓고 결과를 오는 순서대로 받는 방식으로 작동하게 함으로써 프로그램이 특정 구간에서 멈추지 않고 계속 돌아가게 한다. 이는 API나 DB 작업에 관련된 파이썬 코드의 작동 시간을 많이 단축한다.

아래 코드는 asyncio 모듈을 이용해 블로그 글을 크롤링하는 예시이다.

asyncio_example.py

import threading
from concurrent.futures import ThreadPoolExecutor
import asyncio
import timeit
import requests

urls = ['https://jinwoo1990.github.io/git/git-flow-concept/',
        'https://jinwoo1990.github.io/git/git-flow-tutorial/',
        'https://jinwoo1990.github.io/jira/jira-example/']

async def fetch(url, executor):
    print('Thread Name : ', threading.current_thread().getName(), 'Start', url)

    # 실행
    response = await loop.run_in_executor(executor, requests.get, url)
    result_data = response.text

    print('Thread Name: ', threading.current_thread().getName(), 'Done', url)

    return result_data

async def main():
    # 쓰레드 풀 생성
    executor = ThreadPoolExecutor(max_workers=10)

    # future 객체 모아서 gather 에서 실행
    futures = [
        asyncio.ensure_future(fetch(url, executor)) for url in urls
    ]

    # 결과 취합
    result = await asyncio.gather(*futures)

    print()
    print('Result')
    print('갯수: %s' % len(result))
    print('결과: %s' % result)

if __name__ == '__main__':
    start = timeit.default_timer()
    # 루프 초기화
    loop = asyncio.get_event_loop()
    # 작업 완료까지 대기
    loop.run_until_complete(main())
    # 수행 시간 계산
    duration = timeit.default_timer() - start
    # 총 실행 시간
    print('Total Running Time: ', duration)

코드를 보면 먼저 loop = asyncio.get_event_loop() 에서 비동기로 프로그램을 동작시키는 이벤트 루프를 생성한다. 그 다음 loop.run_until_complte(main()) 으로 비동기로 작성된 함수인 main() 함수가 돌며 urls 에 정의된 url에서 데이터를 크롤링해오게 된다.

비동기 함수 내부에서는 await 에서 요청을 보내놓고 결과를 기다리고 그 동안 다른 작업이 진행되는 방식으로 코드가 작동된다.

코드 작동 결과

Thread Name :  MainThread Start https://jinwoo1990.github.io/git/git-flow-concept/
Thread Name :  MainThread Start https://jinwoo1990.github.io/git/git-flow-tutorial/
Thread Name :  MainThread Start https://jinwoo1990.github.io/jira/jira-example/
Thread Name:  MainThread Done https://jinwoo1990.github.io/jira/jira-example/
Thread Name:  MainThread Done https://jinwoo1990.github.io/git/git-flow-concept/
Thread Name:  MainThread Done https://jinwoo1990.github.io/git/git-flow-tutorial/

Result
갯수: 3
결과: [['<!doctype html>\n<!--\n  Minimal Mistakes Jekyll Theme 4.22.0 by Michael Rose\n  Copyright 2013-2020 Mi...', '<!doctype html>\n<!--\n  Minimal Mistakes Jekyll Theme 4.22.0 by Michael Rose\n  Copyright 2013-2020 Mi...', '<!doctype html>\n<!--\n  Minimal Mistakes Jekyll Theme 4.22.0 by Michael Rose\n  Copyright 2013-2020 Mi...']]
Total Running Time:  0.904394618

코드를 돌려보면 요청한 url 순서와 다르게 실제 응답이 온 url 순으로 작업이 완료되어 최종 결과를 취합한 것을 볼 수 있다.

References

Leave a comment