들어가면서

오늘은 실패담을 공유해보고자 합니다. 바로 처리 속도를 높이기 위해 Ray를 도입했다가 오히려 3배 이상 느려져 결국 걷어내고만 이야기입니다.

저는 분산 처리 프레임워크라는 새로운 도구를 도입하면 당연히 빨라질거라고 생각했습니다. 이 전제를 제대로 의심하지 않은게 문제였습니다. 새로운 도구 도입을 고민하고 계신 분들이 가볍게 참고하실 수 있도록 경험을 정리해서 공유합니다.

배경

이번 최적화 대상은 수백만개 오디오 파일을 예측하고 그 결과를 계산해 저장하는 작업이었습니다. 데이터 양이 많은 만큼 배치 작업으로 설계했습니다. 또한, 같은 이유로 시간이 오래걸리는 작업이었기때문에 여러차례 최적화를 거쳐 10일의 수행 시간을 17시간으로 단축시켜놓은 작업이었습니다.

배치 작업 과정에서 순차적으로 진행되던 데이터 준비 작업과 예측 작업을 병렬로 수행하면 시간을 단축 시키고 GPU를 최대한으로 사용할 수 있지 않겠냐는 의견이 제기되었습니다. 이에 따라 아래와 같은 여러가지 방안을 시도해보았습니다.

시도 1: Data Loader

가장 처음 시도한 것은 모델 학습 프레임워크에서 제공하는 Data Loader 였습니다. 이 시도는 실패했습니다. 현재 예측 서버가 Fast API + Celery 조합으로 구축되어있는데 Celery worker 내부에서 Data Loader의 멀티프로세싱이 충돌해서 사실상 병렬 로딩이 불가능했습니다. Data Loader를 적용하려면 Celery를 사용하지 않는 방향으로 리팩토링을 해야했고, Data Loader로 속도가 빨라질지, 빨라진다면 얼마나 빨라질지 모르는 상황에서 선택하기 어려운 옵션이었습니다.

시도 2: Ray

Data Loader의 멀티프로세싱이 Celery와 충돌하는 문제를 피하려면 Celery 외부에서 독립적으로 동작하는 환경이 필요했습니다. 데이터 준비와 예측을 병렬로 처리하면서도 Celery와 독립적인 실행 환경을 제공할 수 있는 방법을 찾다 보니 자연스럽게 분산 처리 프레임워크를 검토하게 됐습니다.

분산 처리 프레임워크로는 Dask와 Ray를 검토했습니다. Dask는 pandas, numpy 기반의 데이터 처리에 최적화된 반면, Ray는 ML 추론처럼 모델 상태를 유지하면서 반복 실행하는 작업에 더 적합한 구조였습니다. Ray의 Actor 모델을 활용하면 모델 가중치를 메모리에 올려두고 재사용할 수 있어, 매 요청마다 모델을 다시 로드하는 비용도 줄일 수 있을 것이라 판단했습니다.

새로운 구조는 Celery를 Orchestrator로 사용하고 Ray로 오디오 로딩 작업과 예측 작업을 분산 처리하는 구조로 설계했습니다.

Ray를 적용한 결과는 놀라웠습니다. 예상과 정반대였습니다. 3배 이상 느려졌고 너무 오래걸려서 중간에 중단했기 때문에 실제로는 더 걸렸을 수도 있습니다.

원인으로 의심되는건 아래 세가지입니다.

1. 싱글 노드 환경에서 병렬화 이득이 작았습니다.

오디오 파일이 인메모리로 처리하기엔 크다 보니 한번에 병렬로 올릴 수 있는 양이 제한되었습니다. 또한, 가용 서버가 한 대 였기 떄문에 Ray를 최대로 활용하기 어려웠고 그러다 보니 병렬화로 얻을 수 있는 이득이 상대적으로 작았습니다.

2. 통신 비용이 병렬화 이득을 초과했습니다.

Celery와 Ray, 그리고 Ray 노드 간 데이터를 주고받는 통신 비용이 병렬화 이득을 초과했습니다.

3. 구조가 이미 현재 구조에 지나치게 최적화 되어있었습니다.

앞서 말씀드린 대로 이미 10일 걸리던 작업 시간을 17시간으로 약 14배 단축시킨 상태였습니다. 현재 구조에 최적화되어 있었기에 Ray 도입이 오히려 비효율을 초래했습니다. Ray 기반으로 다시 최적화를 하려면 코드를 전면 재작성할 필요가 있었습니다. 하지만 앞서 Data Loader와 마찬가지로 비용 대비 기대 이득이 불확실했습니다.

며칠의 기한을 정해놓고 여러가지 방향의 최적화를 시도해보면서 발전시킬 수 있는 가능성을 찾아보기로 했습니다. 안타깝게도 유의미한 결과를 얻지 못했기 떄문에 Ray 도입은 무산되었습니다.

시도 3: Producer-Consumer 패턴 적용

마지막 시도는 현재 구조는 그대로 둔 채 데이터 준비 작업과 예측 작업을 병렬 처리할 수 있는 방법을 찾는 것이었습니다. 저는 데이터를 준비하는 Producer 스레드와 예측을 수행하는 Consumer 스레드를 병렬 수행하는 Producer-Consumer 패턴을 적용했습니다. 여기에 추가 코드 최적화를 더해 총 17시간의 수행 시간을 15시간으로 약 2시간을 단축시킬 수 있었습니다. 관련된 이야기는 기회가 된다면 다른 포스트에서 자세히 다뤄보겠습니다.

결론

서론에서 말했던 것 처럼 저는 은연중에 ‘새로운 프레임워크를 도입하면 당연히 빨라질 것이다’라는 전제로 프로젝트를 시작했습니다. 하지만 그 전제는 상황에 따라 맞을 수도, 틀릴 수도 있다는 점을 간과했습니다. 도입 전에 그 프레임워크의 장점을 잘 활용할 수 있는 상황인지, 현재 구조를 유지한 상태로 최적화 할 수 있는 방법은 없는지를 먼저 고려해보고 결정했어야 했습니다. 그래도 이번에 얻은 교훈을 통해 다음에 새로운 툴 도입시에는 좀 더 신중하게 결정할 수 있을 것 같습니다.

이번 프로젝트에 아쉬운점만 있지는 않습니다. 이야기로만 듣던 Ray를 실제로 써보면서 어떤 구조이고, 어떤 상황에서 Ray의 장점을 잘 활용할 수 있는지 알 수 있었습니다. 아쉽게도 이번에는 실제 도입으로 이어지지는 않았지만, 언젠가 조건이 맞을 때 Ray를 다시 제대로 한번 써보고 싶습니다.