-
[Python] Tip - 진정한 병렬성을 실현하려면 concurrent.futures를 고려언어/파이썬 & 장고 2017. 1. 6. 21:13
파이썬 프로그램을 작성할 때 성능을 충족해야 하는 경우가 있습니다. 코드를 최적화한 이후 조차도 프로그램이 여전히 원하는 수준보다 훨씬 느리게 실행할 수도 있습니다. CPU의 코어 수가 늘어나는 현대의 컴퓨터에서는 병렬성이 한 가지 해결책이라고 봐도 무방합니다. 코드의 연산 부분을 여러 CPU 코어에서 동시에 실행할 수 있게 독립적으로 동작하는 부분으로 나눈다면 어떨까요?
불행하게도 파이썬의 전역 인터프리터 잠금(GIL)이 스레드에서 진정한 병렬성을 막으므로 이 옵션은 사용할 수 없습니다. 일반적으로 제안할 수 있는 다른 방법은 가장 성능이 중요한 코드를 C언어 확장 모듈로 재작성하는 것입니다. C로 작성하면 하드웨어에 더 가까워지고 파이썬보다 빨리 실행할 수 있어서 병렬화할 필요가 없어집니다. C확장도 병렬로 실행하는 네이티브 스레드를 시작해서 여러 CPU 코어를 활용할 수 있습니다. C 확장용 파이썬 API는 문서화도 잘 되어 있어 돌파구로 좋은 선택입니다.
하지만 C로 코드를 재작성하는 데는 상당한 비용이 듭니다. 파이썬에서 이해하기 쉽고 간단한 코드라도 C에서는 장황하고 복잡하게 될 것입니다. 이런 포팅 작업을 할 때는 기능이 원래의 파이썬 코드와 같고 버그가 생기지 않았음을 보장하려고 수많은 테스트를 해야합니다. 때로는 그만한 가치가 있습니다. 텍스트 파싱, 이미지 합성, 행렬 연산과 같은 속도를 향상하는 파이썬의 많은 C확장 모듈 에코 시스템이 이를 증명해줍니다. 심지어 C로 변환하는 작업을 수월하게 해주는 Cython과 Numba라는 오픈 소스 도구도 있습니다.
문제는 프로그램의 한 부분을 C로 옮기는 것만으로는 대부분 충분하지 않다는 점입니다. 최적화한 파이썬 프로그램이 느려지는 데는 보통 한 가지 주요 원인이 아니라 여러 가지 중요한 원인이 있습니다. C의 저수준 기능을 사용할 수 있는 이점과 스레드의 이점을 얻으려면 프로그램의 많은 부분을 포팅해야 합니다. 이런 작업에는 엄청난 테스트가 필요하며 위험도 증가합니다. 분명히 어려운 계산 문제를 해결하려고 파이썬에 집중할 수 있는 더 좋은 방법이 있습니다.
concurrent.futures로 쉽게 접근할 수 있는 내장 모듈 multiprocessing이 바로 이 상황에 필요한 것입니다. 이 모듈을 이용하면 파이썬에서 자식 프로세스로 추가적인 인터프리터를 실행하여 병렬로 여러 CPU코어를 활용할 수 있습니다. 이런 자식 프로세스는 주 인터프리터와는 별개이므로 전역 인터프리터 잠금 역시 분리됩니다. 각 자식은 CPU코어 하나를 완전히 활용할 수 있습니다. 또한 주 프로세스와 연결되어 계산할 명령어를 받고 수행한 결과를 반환합니다.
예를 들어 파이썬으로 여러 CPU 코어를 활용해 계산 집약적인 작업을 한다고 가정합니다. 여기서는 나비어-스톡스 방정식을 이용한 유체 역학 시뮬레이션처럼 더 계산 집약적인 알고리즘 대신에 두 숫자의 최대 공약수를 찾는 알고리즘을 구현해보도록 하겠습니다.
from time import time def gcd(pair): a,b = pair low = min(a,b) for i in range(low, 0, -1): if a % i == 0 and b % i == 0: return i # 병렬성이 없으므로 이 함수를 순서대로 실행하면 시간이 선형적으로 증가 numbers = [(1963309, 2265973), (2030677, 3814172), (1551645, 2229620), (2039045, 2020802)] start = time() results = list(map(gcd, numbers)) end = time() print('Took %.3f seconds' % (end - start)) # 결과 # Took 0.700 seconds
여러 파이썬 스레드에서 이 코드를 실행하면 GIL 때문에 병렬로 여러 CPU코어를 사용하지 못해서 속도가 개선되지 않습니다. 이번에는 concurrent.futures 모듈의 ThreadPoolExecutor 클래스와 작업 스레드 두 개(CPU 코어 개수)를 사용하여 위와 동일한 계산을 수행하겠습니다.
from time import time from concurrent.futures import ThreadPoolExecutor def gcd(pair): a, b = pair low = min(a, b) for i in range(low, 0, -1): if a % i == 0 and b % i == 0: return i # 병렬성이 없으므로 이 함수를 순서대로 실행하면 시간이 선형적으로 증가 numbers = [(1963309, 2265973), (2030677, 3814172), (1551645, 2229620), (2039045, 2020802)] start = time() pool = ThreadPoolExecutor(max_workers=2) results = list(pool.map(gcd, numbers)) end = time() print('Took %.3f seconds' % (end - start)) # 결과 # Took 1.086 seconds
위의 결과는 스레드 풀을 시작하고 통신하는 데 드는 오버헤드 때문에 더 느립니다.
이제 코드 한 줄을 수정하면 마법같은 일이 발생합니다. ThreadPoolExecutor를 concurrent.futures 모듈의 ProcessPoolExecutor로 대체하면 모든게 빨라집니다.
from time import time from concurrent.futures import ProcessPoolExecutor def gcd(pair): a, b = pair low = min(a, b) for i in range(low, 0, -1): if a % i == 0 and b % i == 0: return i # 병렬성이 없으므로 이 함수를 순서대로 실행하면 시간이 선형적으로 증가 numbers = [(1963309, 2265973), (2030677, 3814172), (1551645, 2229620), (2039045, 2020802)] start = time() pool = ProcessPoolExecutor(max_workers=2) results = list(pool.map(gcd, numbers)) end = time() print('Took %.3f seconds' % (end - start)) # 결과 # Took 0.533 seconds
CPU 코어를 2개 사용하여 실행했는데 눈에 띄게 빨라졌습니다. 다음은 ProcessPoolExecutor 클래스가 (multiprocessing 모듈이 제공하는 저수준 구조를 이용해) 실제로 하는 작업입니다.
- numbers 입력 데이터에서 map으로 각 아이템을 가져옴
- pickle 모듈을 사용하여 바이너리 데이터로 직렬화
- 주 인터프리터 프로세스에서 직렬화한 데이터를 지역 소켓을 통해 자식 인터프리터 프로세스로 복사
- 자식 프로세스에서 pickle을 사용하여 데이터를 파이썬 객체로 역직렬화
- gcd함수가 들어 있는 파이썬 모듈을 임포트
- 다른 자식 프로세스를 사용하여 병렬로 입력 데이터에 함수를 실행
- 결과를 다시 바이트로 직렬화
- 소켓을 통해 바이트를 다시 복사
- 바이트를 부모 프로세스에 있는 파이썬 객체로 역직렬화
- 마지막으로 여러 자식에 있는 결과를 반환용 리스트 한 개로 합침
프로그래머 입장에서는 위의 단계가 간단하게 보일지라도 multiprocessing모듈과 ProcesspOOLeXECUTOR 클래스는 병렬성을 가능하게 하려고 상당히 많은 일들을 합니다. 대부분의 다른 언어에서는 두 스레드를 조율해줘야 하는 부분은 단일 잠금이나 원자적 연산뿐입니다. multiprocessing의 비용은 부모와 자식 프로세스 간에 일어날 수 밖에 없는 모든 직렬화와 역직렬화 때문에 상당히 높습니다.
이 방법은 고립되고 지렛대 효과가 큰 특정 유형의 작업에 적합합니다. 여기서 고립(isolated)이란 프로그램의 다른 부분과 상태를 공유할 필요가 없는 함수를 의미합니다. 또한 지렛대 효과가 크다(high-leverage)는 건 부모와 자식 프로세스 사이에서 데이터를 조금만 전송해도 많은 양의 계산이 일어나야 한다는 의미입니다. 최대 공약수 알고리즘이 이런 예 중 하나지만, 다른 수학적 알고리즘도 대부분 비슷하게 동작합니다.
계산이 이와 같은 특성을 갖추고 있지 않으면 multiprocessing의 비용이 병렬성을 통한 속도 향상을 막을 수도 있습니다. multiprocessing은 이런 상황에 쓸 수 있는 공유 메모리, 프로세스 간 잠금, 큐, 프록시 같은 고급 기능을 제공합니다. 하지만 이런 기능 모두가 너무 복잡합니다. 파이썬 스레드 간에 공유하는 단일 프로세스의 메모리에서 이런 도구의 동작을 설명하기는 어렵습니다. 이 복잡도를 다른 프로세스로 확장하고 소켓과 연관지으면 훨씬 이해하기 어려워집니다.
그러므로 multiprocessing의 모든 기능을 직접 사용하지 말고, 더 간단한 concurrent.futures 모듈을 통해 사용하도록 권장합니다. ThreadPoolExecutor클래스를 사용하여 고립되고 지렛대 효과가 큰 함수를 스레드에서 실행하는 것부터 시작하면 됩니다. 나중에 속도를 향상하려면 ProcessPoolExecutor로 옮겨가면 됩니다. 마지막으로 다른 옵션이 모두 바닥이 나면 multiprocessing 모듈을 직접 사용하는 방안을 고려해도 됩니다.
요약
CPU 병목점을 C확장 모듈로 옮기는 방법은 파이썬 코드에 최대한 투자하면서 성능을 개선할 수 있는 효과적인 방법. 하지만 이렇게 하면 비용이 많이 들어가고 버그가 생길 수도 있음
multiprocessing 모듈은 파이썬에서 특정 유형의 계산을 최소한의 노력으로 병렬화 할 수 있는 강력한 도구를 제공
multiprocessing의 강력한 기능은 concurrent.futures와 그 안에 들어 있는 간단한 ProcessPoolExecutor 클래스로 접근하는 게 가장 좋음
multiprocessing 모듈의 고급 기능은 너무 복잡하므로 피하는 것이 좋음