멀티프로세싱을 이용하면 멀티코어 CPU 상에서 성능을 드라마틱하게 향상시킬 수 있습니다. CentOS 7.5 의 C로 작성한 프로세스에서 동작하는 퍼포먼스 크리티컬한 파이썬 스크립트를 작성중에 있는데, 멀티 프로세스 처리를 C 언어에서 할 지, 파이썬 에서 할 지 고민하다 작성하게 됐습니다. 이 글은 pyton 2.7 을 기준으로 작성했습니다. 먼저 아래 코드를 보겠습니다.
def md5_million_times(msg):
md5 = hashlib.md5()
for i in range(1000000):
md5.update(msg)
msg = md5.hexdigest()
print 'Result : %s' % msg
def main():
start = time.time()
md5_million_times('Hello World')
print 'Time : %s' % str(time.time() - start)
if __name__ == '__main__':
main()
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Time : 1.0720000267
이 코드는 문자열에 대해 md5 해쉬를 백만번 반복하는 함수입니다. 결과창에서 보듯이 1.07초가 소모됐습니다. 이 함수는 100번 호출해야 한다면 어떻게 될까요? 다음 코드를 보시겠습니다.
def main()
start = time.time()
for i in range(100):
md5_million_times('Hello World')
print 'Time : %s' % str(time.time() - start)
>>> ...
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Time : 103.657000065
소요시간이 선형으로 증가하는걸 확인할 수 있습니다. 더불어 아래는 이 코드가 실행중인 동안 CPU 그래프입니다.
이번엔 multiprocess 를 활용해 같은 일을 해보겠습니다. 코드는 아래와 같습니다.
def main():
procs = list()
start = time.time()
for i in range(100):
proc = multiprocessing.Process(target=md5_million_times, args=('Hello World',))
procs.append(proc)
proc.start()
for proc in procs:
proc.join()
print 'Time : %s' % str(time.time() - start)
>>> ...
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Time : 33.378000021
약 1/3 가량으로 소요시간이 줄어든 것을 확인할 수 있습니다. CPU 점유율 또한 고르게 분포돼있습니다. multiprocess 모듈을 사용할 시, CPU 스케줄링은 OS 가 담당합니다. 독립적인 프로세스로 실행되기에 코어가 많다면 성능 향상을 기대할 수 있습니다.
단순한 연산이라면 문제가 없지만, 연산을 위한 초기화 과정(이를테면 __init__()
이 오래걸리는 함수라면 어떨까요? 매번 프로세스를 생성할 때 마다 초기화과정에 오랜 시간이 소요되기에 실제 멀티프로세싱으로 얻는 이득은 크지 않을 것입니다.
이런 경우를 위해 multiprocessing 패키지는 IPC(Inter Process Communication) 방법으로 Queue 와 Pipe 를 제공합니다. 초기화를 끝낸 프로세스에게 필요할 때만 인자를 전달해 일을 시킬 수 있도록 말이죠.(물론 인자값 전달, 리턴값 수신등의 용도로도 필수적입니다) 통신 방식에 약간의 차이는 있으니 자신에게 적합한 방식은 Exchanging objects between processes 를 참고토록 합니다.
def md5_million_times(pipe):
# very long initializing time
time.sleep(10)
# get argument from parent
msg = pipe.recv()
while msg:
# end process
if msg == 'exit':
break
# calculate hash
else:
md5 = hashlib.md5()
for i in range(1000000):
md5.update(msg)
msg = md5.hexdigest()
print 'Result : %s' % msg
# standby for further use
msg = pipe.recv()
# send time on process ends
pipe.send(time.time())
def main():
procs = list()
parent_pipes = list()
# create 10 processes with pipe
for i in range(10):
# create pipe
parent_pipe, child_pipe = multiprocessing.Pipe()
# store pipe for further use
parent_pipes.append(parent_pipe)
# create process
proc = multiprocessing.Process(target=md5_million_times, args=(child_pipe, ))
# store process handler
procs.append(proc)
# start process
proc.start()
start = time.time()
# call 100 times for each 10 processes
for i in range(100):
index = i % len(parent_pipes)
parent_pipes[index].send('Hello World')
# terminate processes
for pipe in parent_pipes:
pipe.send('exit')
# gather process end times
ends = [p.recv() for p in parent_pipes]
# pick latest one
end = max(ends)
print 'Time : %s' % str(end - start)
if __name__ == '__main__':
main()
>>> ...
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Result : 1e4110cf294414bc2ac346ad274a7af3
>>> Time : 31.5739998817
코드가 길어지면서 주석을 달아놨습니다. 쭉 읽어보시면 쉽게 파악하실 수 있을겁니다. 더불어 좀 전 100 개의 프로세스를 실행했을보다 동작시간이 31초로 2초가 줄었습니다. 이 31초는 각 프로세스의 초기화 시간 time.sleep(10)
이 포함된 수치로 실재 연산시간을 더 짧을 것입니다. 이는 프로세스 생성/종료에 소요되는 자원이 그만큼 많기에 적절한 프로세스 수를 유지하는 것이 더 효율적임을 의미합니다.
파이썬에서 멀티프로세스 처리를 하면 여러가지 잇점을 얻을 수 있습니다.
Embedded python 래퍼가 간단해 진다
C에서 파이썬을 호출해야 하는 상황인데, 여러 인스턴스 관리를 C에서 하게 되면 그만큼 래퍼 작성이 어려워집니다. 가뜩이나 자료형 선언하는것도 귀찮은데 말이죠…
IPC 가 쉽다
Inter Process Communicate 는 멀티프로세스 처리에 있어 필수적입니다. 하지만 윈도우즈와 리눅스 competible 한 코드를 작성하기엔 NamedPipe, FIFO 등 OS 단에서 차이가 납니다. multiprocessing 패키지를 사용한다면 무시해도 되는 문제이지요. 게다가 C embedded python 환경에서도 프로세스 독립적인 통신수단을 제공하기에 IPC 충돌을 걱정할 필요가 없으니 코드가 더욱 간결해집니다.
여러가지라 써놓고 두가지밖에 생각이 안나네요… 물론 처리속도에 있어 C를 따라잡을 순 없겠지만 C와 파이썬을 번갈아가며 삽질해 본 결과, 코드의 간편함과 멀티프로세싱으로 얻는 속도향상이 C와 파이썬의 속도차이를 커버하고 남는다는 느낌입니다. 누군가에겐 도움이 됐기를 바랍니다.