좌충우돌 병렬 프로그래밍

insukL·2024년 5월 28일
post-thumbnail

서론

새로운 프로젝트를 진행하면서 Python을 사용해서 서비스를 구성했다. 서비스 내에 AI 모델을 호출하는 과정이 있었고, 이를 HTTP 기반으로 통신을 진행했다.

동기적인 코드로 구성된 서비스는 AI 모델 성능에 따라 큰 차이가 발생했고, 이를 비동기로 바꾸었으나 동일한 속도가 측정됐다. 내가 Python 코드를 못 짰나? 찾아본 내용을 정리해보고자 글을 작성한다.

비동기로 짠다는 것

비동기란?

동기와 비동기

흔히 동기와 비동기에 대해 설명하자면 위의 그림을 볼 수 있다. 간단하게 보자면 동기는 작업의 실행 순서를 지켜 작업을 진행하는 방식이고, 비동기는 그와 무관하게 작업을 진행한다.

순서와 무관하게 진행하면서 비동기 방식은 코드 순서 때문에 대기하는 경우가 없기 때문에 CPU의 유휴 상태를 효과적으로 관리하는 이점이 있다.

보기엔 비동기 작업만 성공하면 어플리케이션의 속도가 눈에 띄게 향상할 것 같다. 왜? CPU 유휴 상태를 효과적으로 처리하니까.

동기? 블로킹?

어떻게 보면 비동기 통신은 앞선 작업을 대기하지 않는 것으로 보인다. 편의상 비동기 + 논 블로킹 작업을 지칭하지만 이론적으론 엄연한 차이가 있다고 한다. 해당 내용은 구글링하면서 더 잘 정리해둔 블로그가 있으니 첨부한다.

Blocking, Non-blocking, Sync, Async의 차이
완벽히 이해하는 동기/비동기 & 블로킹/논블로킹

나는 왜 비동기를 하려고 했는가

내가 실수한 부분을 짚기에 앞서 어떤 상황을 통해 비동기 작업을 진행하려고 했는지, 어떻게 해결해 나갔는지 이야기해 보겠다.

1. 느린 문장 분리 라이브러리

프로젝트는 블로그 글을 가져와서 하나의 문단으로 만들고, 이를 패턴을 기반으로 한국어 문장으로 분리하는 라이브러리를 사용했다. 패턴 기반으로 동작하면서 다소 긴 시간 동안 문장 분리가 실행됐다.

2. AI 모델 동작

내부에서 문장에 대한 광고 판단, 이미지에 대한 광고 판단, 긍정/부정 평가 등 3가지 모델이 동작하면서 모델 동작에 비교적 긴 시간이 걸렸기 때문에 이로 인한 시간이 오래 걸리는 문제가 있었다.

척 봐도 2가지 요소에 의해 시간이 오래 걸린다. 실제로 11개의 URL을 요청한 결과 응답까지 10분 이상 걸렸다.

비동기로 바꿔보기

나는 각각에서 제일 병목 현상이 발생하는 부분에 대해 생각했다. 문장 분리와 AI 동작 과정에서 발생하는 CPU Burst가 가장 큰 문제라고 생각했다.

CPU Burst?

어떤 프로세스가 동작함에 있어 I/O Waiting, 다시 말해서 입출력에 의한 대기 시간이 CPU가 작업하는 시간보다 긴 작업을 I/O Bound라고 말한다.

반대로 CPU가 작업하는 시간이 입출력에 대해 대기하는 시간보다 긴 경우 CPU Bound라고 한다.

어떤 작업에 대해 CPU Bound가 더 큰 작업을 CPU Burst, I/O Bound가 더 큰 작업을 I/O Burst라고 한다.

오히려 느려진 코드

그래서 CPU를 더 잘 사용할 수 있도록 비동기 처리를 해주면 되지 않을까?라고 생각했다. 한 URL에 대해 1분가량 걸리니까 비동기로 작업하면 1분 정도에 모든 URL에 대해 작업이 완료되지 않을까 싶었다. 비동기 처리를 통해 이것이 가능하므로 FastAPI 내의 async/await를 활용하면 될 것이라 생각했다.

결과만 말하자면 오히려 느려졌다. 동기로 동작하는 경우 650초 전후가 걸렸는데, 이후 750초까지 늘었다. 내가 무엇을 잘못했을까? 그 전에 Python의 비동기 동작에 대해 알아야 한다.

Coroutine, 코루틴

멀티 스레드는 프로세스 내에 스레드 별로 다르게 업무를 할당한다. Python은 기본적으로 싱글 스레드로 작업하고 이러한 멀티 스레드 작업을 위해 코루틴이라는 기능을 지원한다.

Python은 언제든지 함수를 잠깐 멈추고, 다른 작업을 진행할 수 있는 코루틴이라는 개념을 지원한다. 이는 async/await를 통해 사용할 수 있다.

여기서 나는 async/await를 사용했으니 코루틴을 사용했다. 여기서 문제가 생기는데, 코루틴은 동시성은 지원하지만 병렬성은 지원하지 않는다.

동시성? 병렬성?

이전에 봤던 비동기 사진을 보면 각각의 작업이 동시에 이뤄지는 것으로 보인다. 하지만 엄밀히 말해서 동시성과 병렬성으로 특징이 구분된다.

동시성과 병렬성

출처: baeldung Concurrency vs Parallelism

그림을 보면 금방 알 수 있다. 코루틴은 단일 스레드에서 동작한다. 다시 말해서, 하단 Parallel 부분에 해당하는 병렬성도 없고 동시성의 경우엔 일의 총량이 변하지 않고 잘라서 일한다는 뜻이다.

그럼 왜 더 느려지는데?

동시성과 병렬성 비교 그림을 보면 알지만 동시성의 경우엔 일을 잘게 쪼개서 진행한다. 그렇다고 각 프로세스가 같은 프로세스는 아니다. 다시 말해서 Context Switching이 발생한다.

Context Switching?

CPU가 작업을 처리하고 있을 때, 인터럽트 등에 의해 해당 작업을 잠시 중단하고 다른 프로세스나 스레드를 CPU에서 작업하게 된다.

이 과정에서 기존 프로세스나 스레드의 진행도를 저장해두고, 다른 프로세스나 스레드의 작업이 종료되면 기존 작업을 원복한다. 이 과정을 Context Switching이라고 한다.

Context Switching은 단순히 CPU에 올라가 있는 작업이 변경되고, CPU는 아무 일도 할 수 없는 순수한 오버헤드다. 그런데 기존에 동기 방식은 작업 하나만 완수하기 때문에 Context Switching이 발생하지 않는다. 하지만 비동기 방식은 수시로 작업이 변경되면서 잦은 Context Switching이 발생한다.

이 과정에서 오버헤드가 쌓여 비동기 방식이 오히려 느린 상태가 발생했다.

GIL

문제점을 찾았으니 멀티 스레드로 바꾸면 될 것 같았다. 하지만 멀티 스레드도 정답은 아니었는데, 이는 Python의 GIL(Golbal Interpreter Lock) 정책 때문이었다.

Python은 참조 횟수를 기반으로 GC를 진행하는데, 이는 Thread-Safe하지 않다. 이로 인한 문제를 방지하기 위해 전체 중 하나의 스레드에서만 인터프리터가 동작할 수 있도록 제한한다. 이것을 GIL이라고 부른다.

인터프리터의 동작이 제한되기 때문에 결과적으로 병렬성을 활용한 행동에 제한받는다. CPU는 결국 하나의 인터프리터를 통해 진행되므로 스레드 기반의 병렬 프로그래밍은 불가능했다.

해결 과정

AI 모델 서버 분리하기

현재 프로젝트는 AI 모델에 대해 따로 API 서버를 구축했다. 시간과 러닝 커브를 고려하여 메시지 큐를 사용한 아키텍처는 구성하지 않았고, HTTP를 통해 요청과 응답을 진행했다.

내부적으로 Docker Compose로 Docker Network를 구성해서 네트워크를 통한 통신을 피하고, 컨테이너 간의 통신을 진행하도록 했다.

결과는 변함 없음

I/O Burst에 따른 속도 이슈가 해소되면서 다소 속도가 올랐으나, 다이나믹한 변화를 보이진 않았다. cProfile을 통해 확인해본 결과 요청 후 대기하지 않고, 로직이 진행됨이 확인되었다. 하지만 AI 모델을 사용하면서 모델 구동에 따른 시간이 단축되지 않았다는 부분이 컸다.

멀티 프로세스

AI 모델을 구동하는 코드 내에 멀티 프로세스를 적용하기로 했다. GIL를 피하기 위한 방법은 멀티 프로세스를 통한 처리라고 생각했다. 하지만 AI 모델 분리 이전에 멀티 프로세스를 적용해보려 했다가 메모리 부족이 발생했기 때문에 적은 수의 프로세스를 가지고 작업을 진행했다.

응답은 왜 안해주는데

팀원과 멀티 프로세스로 변환하고, 서버에 배포하면 계속 API 담당 컨테이너에서 Timeout 에러를 뱉었다. 이게 Timeout 시간을 3분에서 5분 가량으로 늘려서 설정해뒀는데, 이전에도 1분 내에 실행됐으므로 말이 안됐다.

docker stats을 통해서 CPU 사용량을 확인해보았는데, AI 모델 서버가 잠깐 CPU를 점유하는 듯하더니 그대로 0~1퍼를 유지하면서 FastAPI 어플리케이션만 돌아감을 확인했다.

로깅

다양한 의견이 나왔으나 결국은 모두 근거가 없었다. 그런 와중 멀티 프로세스는 별도의 프로세스에서 실행되기 때문에 로그 자체가 남지 않았을 거란 생각이 들었다. 때문에 로깅을 하자고 의견을 냈다.

부모 프로세스에게 로그 데이터를 넘겨 로깅을 통해 확인한 결과 AI 모델을 사용하는 과정에서 해당 프로세스가 가만히 멈춰있음을 확인했다.

데드락

프로세스가 동작하는데 그대로 멈춰서 대기한다? 머릿속에 데드락이 스쳤는데, 문제는 로컬 환경에서는 제대로 동작한다는 점이었다.

이에 로컬 환경과 서버 환경의 차이에 주목했다. 아무래도 가장 큰 차이는 OS의 차이였다. 로컬 환경은 윈도우를 통해 진행하고, 서버는 Ubuntu로 리눅스 기반의 환경에서 진행했다.

여기서 멀티 프로세스 과정에서 문제가 생겼기 때문에 OS별로 다른 프로세스 생성 방법에 대해 조사했다.

Window

spawn 방식을 사용하는데, 이 방식은 별도의 프로세스 생성한다.
그리고 자식 프로세스 생성 시 부모 프로세스의 메모리를 그대로 복제하지 않는다

Linux

fork 방식을 사용하는데, 이 방식은 프로세스 생성 시 부모 프로세스를 복제한다
자식 프로세스는 부모 프로세스의 상태와 메모리를 복제하므로 메모리가 공유된다

여기서 이전에 학습한 개념을 정리하고 가정을 세웠다.

  1. AI 모델은 모듈을 통해 객체로 import한다.

  2. FastAPI는 싱글 프로세스, 비동기 동작으로 여러 요청을 처리한다.

  3. 리눅스 환경에선 fork 방식을 사용하면서 자식 프로세스는 부모 프로세스의 메모리를 그대로 사용한다.

  4. 여러 요청이 들어오면 fork를 통해 생성된 프로세스가 동작하면서 각각 AI 모델을 참조한다.

  5. 각각의 프로세스가 로드된 AI 모델을 참조하는 과정에서 데드락이 발생했다.

해당 가정을 바탕으로 자식 프로세스 생성 과정을 spawn으로 명시적으로 설정했고, 멀티 프로세스에 성공해 1분 내외 가량 걸리던 응답 시간을 5초에서 10초 내외로 줄일 수 있었다.

마무리

사건의 흐름에 따라 문제와 해결 방안을 적어 중구난방이기 때문에 느낀 점을 다시 정리해보고, 정리하는 김에 느낀 점을 추가해본다.

비동기는 빠르다?

대체로 그렇다. 하지만 GIL과 같은 언어의 정책에서 병렬성을 지원하는지 고려하고, 본인이 비동기 처리하려는 작업이 CPU Bound인지, I/O Bound인지 고려할 필요가 있다. CPU Bound 작업이라면 비동기 처리를 하더라도 결국 CPU에서 작업하는 시간이 필요하기 때문에 단축되지 않을 수 있다.

그리고 해당 작업이 처리되는 속도에 따라 Context Switching으로 인한 오버헤드가 더 클 수 있다. 고로 본인이 처리하려는 내용을 면밀히 살피고, 설계하는 것이 필요하다.

무조건 비동기로 구성하면 안되나요?

안된다. 비동기로 구성하면서 코드의 가독성이 낮아지고, 관리가 어려울 수 있다. 그리고 멀티 프로세스를 사용하는 경우 별도의 프로세스를 생성해서 진행한다. 그렇기에 로깅하고 이를 병합하는 처리를 해두지 않았다면 디버깅이 매우 힘들다.

CS 지식이 도움이 됐어요

해결 과정을 보면 Context Switching, CPU Bound, I/O Bound라는 단어나 멀티 스레드, 멀티 프로세스와 같은 내용은 운영체제 과목에서 들어볼 수 있다. 그리고 데드락이 발생했음을 파악하지 못했다면 영영 왜 그런지 알 수 없었을 것이다. 문제 해결은 CS 지식과 기술 스택의 이해에서 출발한다는 것을 몸소 체감했다.

profile
데이터를 소중히 여기는 개발자가 되고 싶습니다

0개의 댓글