왓챠 추천 서비스 MLOps 적용기 Part2
안녕하세요. 왓챠 ML팀에서 머신러닝 엔지니어로 일하고 있는 찰스입니다.
이전 글에서는 기존 왓챠 ML 파이프라인 및 실험 환경이 가진 문제점에 대해서 살펴보고, 문제를 해결하기 위해 컨테이너 환경의 도입, On-premise GPU 서버와 클라우드 서비스와의 연동, ML 파이프라인과 실험 환경을 제공하기 위해 여러 서비스를 활용한 사례에 대해 살펴보았습니다.
이렇게 여러 해결책을 점진적으로 도입한 후 왓챠 ML 파이프라인 및 실험 환경은 기존에 비해 사용성과 안정성에서 많은 개선을 이루었습니다. 다만, 학습된 모델을 서빙하고 모니터링하는 과정은 여전히 개선해야 할 영역으로 남아있었습니다.
이 글에서는 왓챠 추천 시스템에서 학습된 추천 모델을 서비스에 반영하기 위해 독립적인 추론 서버가 왜 필요했고 어떻게 개발했는지, 그리고 추론 서버와 추천 모델의 성능을 모니터링하기 위해 어떠한 노력을 기울였는지에 대해서 알아보도록 하겠습니다.
기존 왓챠 추천 시스템의 문제점
기존 왓챠 추천 시스템은 추천 모델을 학습하고 추천에 필요한 여러 데이터를 정제하는 워커(Worker)와 워커에서 생성한 모델과 정제된 데이터를 이용하여 API 형태로 추천 결과를 제공하는 서비스(Service)로 이루어져 있었습니다.
이 중 워커는 이전 글에서 설명 드린대로 On-premise GPU 클러스터 내 Argo workflow를 기반으로 작성된 ML 파이프라인으로 이전되었고, ML 파이프라인에서 생성된 여러 모델 및 정제 데이터는 AWS S3와 Redis Pubsub을 통해 서비스로 동기화되어 실시간 반영되고 있었습니다.
서비스에서는 업데이트된 학습 모델과 정제 데이터를 활용하되, 하나의 애플리케이션에서 추천 후보 필터링, 전처리, 모델 추론, 후보정과 같은 과정을 거쳐 API 형태로 제공되었으며, 왓챠/왓챠피디아 내 추천 및 랭킹을 필요로 하는 클라이언트로부터 전달받은 여러 요청을 처리하고 있었습니다.
이러한 구조의 추천 서비스는 추천 모델과 추천에 필요한 데이터를 애플리케이션 내 메모리에 올려두어 사용하기 때문에 처리 속도가 매우 빠르다는 장점을 가지고 있었지만, Monolithic 서비스 특성상 특정 로직의 수정만으로도 서비스 전체에 배포되어야 하고, 모델의 문제가 추천 서비스 전체 장애로 퍼질 수 있다는 문제점을 가지고 있었습니다.
그리고 서비스는 동시성(Concurrency)과 비동기(Asynchronous)를 최대한으로 활용하기에 적합한 Scala로 구현되어 있어 PyTorch로 학습된 추천 모델을 추론하기 위해서는 PyTorch JNI와 같은 Java library가 필요했습니다. 아쉽게도 Pytorch JNI는 Pytorch에서 직접 관리하지 않아 버전 업데이트가 느렸으며 학습과 추론에 효율적인 기능이 PyTorch에 업그레이드되어도 JNI 버전이 업데이트되지 않아 실 서비스에서 빠르게 적용하기가 어려웠습니다. 또한, JNI를 이용하여 모델을 추론했을 때 기본적인 Forward 이외에 지원되는 기능이 제한되어 있어 추론에 필요한 여러 정보를 별도의 관리해야 하는 불편함이 있었습니다.
독립적인 추론 서버 구축
왓챠 ML 팀에서는 위에서 언급한 문제점을 해결하기 위해 독립적인 추론 서버를 구축하기로 했습니다. 독립적인 추론 서버를 구축하면 추가적인 서버 비용 및 운영 비용이 발생할 수 있지만, 앞서 언급한 문제점을 모두 해결할 수 있을 것이라 기대했습니다.
저희는 몇 가지 요구사항을 정해서 만족하는 추론 서버 프레임워크를 찾아보기로 했습니다.
- Pytorch로 학습된 모델(Pytorch eager model, TorchScript) 지원 여부
- 현재 왓챠/왓챠피디아 추천 시스템은 학습 프레임워크로 PyTorch만을 활용하므로, PyTorch에서 학습된 모델을 지원할 수 있는 서빙 프레임워크가 필요했습니다.
- Tensorflow와 같은 다른 학습 프레임워크에서 학습된 모델도 지원하면 좋으나 필수적인 요소는 아니었습니다.
2. HTTP 또는 gRPC 프로토콜 지원 여부
- 왓챠 서비스는 내부적으로 범용적인 통신 프로토콜인 HTTP나 gRPC을 이용해 통신하므로, 두 프로토콜 중 최소 하나의 프로토콜을 지원해야 했습니다.
3. CPU 추론 최적화 여부
- 왓챠 추천 서비스에 직접적으로 활용하는 모델들은 CPU 추론만으로도 충분한 성능을 낼 수 있는 경량화된 모델이므로 CPU 환경에서 최적화된 성능을 낼 수 있는 프레임워크가 필요했습니다.
- 추천을 제외한 일부 작업들은 GPU 추론이 필요했으나, 이는 실시간으로 트래픽을 받아서 처리해야 하는 작업이 아닌 배치 작업에 필요한 추론이었기 때문에 서빙 프레임워크의 GPU 추론 최적화가 필수 요구 조건은 아니었습니다.
4. 모니터링 용이 여부
- 독립적인 추론 서버는 모델의 추론 속도뿐 아니라 지연 시간(Latency), 처리량(Throughput), CPU 사용량, 메모리 사용량 등 주요 서버 지표도 실시간으로 모니터링할 수 있어야 합니다.
- 전사적으로 사용하고 있는 모니터링 도구인 데이터독(Datadog)과의 연동이 잘 되어야 했습니다.
5. Dynamic batching 지원 여부
- Dynamic batching을 지원하는 서빙 프레임워크는 추론 서버에 들어온 요청 샘플들을 동적인 특정 미니 배치 단위로 묶어 모델에 추론을 요청하고 추론 결과를 받아볼 수 있는 기능을 제공했습니다.
- Dynamic batching을 활성화하면 지연시간은 약간 늘어날 수 있으나 처리량을 늘려 많은 수의 요청이 들어와도 안정적으로 추론 결과를 전달할 수 있어 지원 여부를 확인할 필요가 있었습니다.
위와 같은 요구 사항을 만족하는 추론 서버 프레임워크를 찾기 위해 TorchServe, Triton, Seldon Core, Fast API를 후보로 하여 각 프레임워크의 장단점을 비교했습니다.
우선 Fast API는 경량화된 웹 프레임워크이기 때문에, PyTorch로 학습된 모델을 추론하고 새로 학습한 모델을 교체하는 등의 모델 추론 서버의 기능을 거의 지원하지 않았습니다. 결과적으로 개발 자유도는 가장 높았지만, 저희가 추론 서버에 필요한 기능을 거의 모두 직접 구현해야 해서 고려 대상에서 가장 먼저 제외했습니다.
NVIDIA Triton은 NVIDIA에서 만든 추론 서버 프레임워크로 NVIDIA GPU를 활용하여 고성능의 추론을 제공하고 추론 서버가 갖춰야 할 클러스터링, 노드 밸런싱, 오토 스케일링, Dynamic batching 등이 가능하여 대부분의 요구 사항을 만족했습니다. 다만, CPU 실행 환경을 직접 빌드 해야 하고 CPU 추론에 대한 지원이 미흡해서 후보에서 제외되었습니다.
Seldon Core는 쿠버네티스 기반의 오픈소스 프레임워크로 대부분의 요구 사항을 만족했지만, Datadog integration을 직접적으로 지원하지 않고, 쿠버네티스에 특화된 프레임워크이기에 환경 설정에 대한 자유도가 적다는 점이 아쉬워 제외했습니다.
반면에 TorchServe는 모든 요구 조건을 부합하면서도 PyTorch 모델에 특화된 추가적인 최적화 기능들을 제공했습니다. 그리고 배포 및 추론에 특화되어있는 프레임워크답게 모델 배포, 관리, 스케일링, 모니터링과 같은 필수적인 기능을 모두 지원하여 추론 서버를 구축하는데 부족함이 없이 활용할 수 있다고 판단했습니다.
특히, IPEX(Intel extention for pytorch)를 통해 intel 기반의 CPU 환경에서 최적화가 잘 되어있었고, PyTorch ECO-system에 포함되어 지속적인 성능 향상 및 지원 확장에 대해 기대할 수 있다는 장점이 있었습니다. 위와 같은 이유로 저희는 TorchServe를 이용하여 추론 서버를 개발하기로 했습니다.
TorchServe로 추론 서버 개발하기
기존 모델 추론 방식은 추천 서비스 내 학습된 추천 모델을 직접 메모리에 올려 추론하는 방식이었기에 최소한의 모델 추론 속도 최적화만 필요했으나, 별도의 추론 서버를 구성하게 되면 추론 서버와의 네트워크 비용이 필연적으로 발생하기 때문에 전반적인 최적화가 필요했습니다.
우선, 별도의 서버를 구성하여 추론 모델 서비스를 제공하므로 지연시간 및 처리량 등과 같은 서버 주요 지표에 대한 성능 최적화가 필요했습니다. 또한, 다양한 경량화 기법으로 모델의 추론 속도를 빠르게 하면서도 추천 모델의 정확도는 최대한 유지할 수 있어야 했습니다. 이 두 가지 측면에서 최적화를 이룰 수 있도록 어떠한 노력들을 했는지 살펴보도록 하겠습니다.
1. 추론 서버 최적화
a. TorchServe 옵션 최적화
TorchServe는 학습된 모델을 빠르고 안정적으로 추론하기 위해 여러 옵션을 제공합니다. 이 중 저희가 테스트를 했을 때 성능 개선에 유효했던 옵션들에 대해서 살펴보도록 하겠습니다.
- default_workers_per_model
- 이 옵션은 각 모델마다 기본 몇 개의 워커를 할당할지 결정하는 옵션입니다. 저희는 추천 모델마다 별도의 TorchServe를 구성하지 않고 여러 추천 모델을 동시에 올려서 사용하는 것을 전제로 했기에 이 옵션이 비교적 중요했습니다.
- 이 값은 현재 가용한 CPU 개수와 밀접한 관련이 있는데, CPU 개수보다 너무 많은 워커 프로세스가 생성되면 CPU에 할당되는 워커 프로세스가 변경될 때마다 불필요한 오버헤드가 발생할 수 있습니다. 또한, 너무 적은 워커 프로세스를 생성하면, 유후(Idle) CPU 코어가 생기므로 적절한 숫자를 고려해야 합니다.
- 또한, 워커 프로세스가 증가할 때마다 모델을 메모리에 올려야 하므로 메모리 사용량을 고려하여 조정해야 합니다.
- 저희는 여러 실험을 해본 결과, default_workers_per_model는 활용 가능한 physical core 개수와 동일할 때 가장 좋은 효율을 보였습니다. (참고로 pytorch의 thread count는 1로 고정하여 테스트를 진행하였습니다.)
2. ipex_enabled
- IPEX(intel pytorch for extension)는 pytorch로 학습된 모델이 intel CPU 위에서 동작할 때 최적화해주는 도구입니다. 해당 옵션을 활성화하면 별다른 처리를 하지 않아도 최적화 과정을 거쳐 추론을 하게 됩니다.
- 특별한 경우가 아니라면 활성화하는 것이 성능 향상에 도움이 됩니다.
3. cpu_launcher_enable, use_logical_core
- 기본적으로 CPU logical thread를 위에서 GEMM(General matrix multiply) 연산을 수행할 때 병목이 발생할 수 있기 때문에 physical core에 고정(pinning) 하는 것을 권장합니다.
- cpu_launcher_enable 옵션은 physical core 혹은 logical core에만 프로세스를 할당하고, use_logical_core 옵션을 통해 제어할 수 있습니다.
- 저희는 physical core에만 고정할 수 있도록 옵션을 지정했고, physical core에 고정했을 때 성능 차이에 대한 자세한 내용은 링크에서 확인하실 수 있습니다.
4. batch_size, batch_delay
- Dynamic batching을 활용하기 위해서는 모델에서 한 번에 처리할 미니 배치의 최대 사이즈인 batch_size와 dynamic batching을 수행할 최소 대기 시간인 batch_delay를 지정해야 합니다.
- 이는 모델의 특성과 입력 값에 따라 최적화된 값이 다를 수 있으므로 실험을 통해 최적화된 값을 지정하는 것이 좋습니다.
이 외에도 NUMA(Non uniform memory access) 활용, OpenMP 활용, Memory allocator 변경 등의 CPU 관련 최적화 작업은 링크1, 링크2에서 참고하시기 바랍니다.
b. 네트워크 비용 최소화
기존 왓챠/왓챠피디아 추천 서비스에서는 추론 모델을 Torchscript로 빌드 하여 애플리케이션 내에서 직접 추론했습니다. 그렇기 때문에 별도의 통신 비용이 발생하지 않았으나 별도의 추론 서버를 분리한 이후에는 필연적으로 네트워크 비용이 발생할 수밖에 없었습니다. 특히, 사용자의 감상, 평가 같은 이력 데이터(Historical data)는 계속 데이터가 늘어나게 되어 이를 입력 값으로 사용하는 모델은 많은 네트워크 비용을 발생시킬 가능성이 높아지게 됩니다. 저희는 이런 문제를 해결하기 위해 아래 방법을 활용했습니다.
- 캐싱(Caching)
- 추천 모델에서는 사용자의 다양한 행동 이력을 활용하는 경우가 많습니다. 일반적인 경우에는 문제가 되지 않지만, 많은 이력 데이터를 가지고 있는 일부 헤비 유저들의 추천을 위해 반복적으로 이력 데이터를 모두 전달하는 것은 비효율적입니다. 저희는 일부 헤비 유저들의 이력 데이터를 메모리 캐시에 저장하고 최근 발생한 이력 데이터만 추론 서버에 요청하면, 캐싱 된 이력 데이터와 병합한 후 추론하는 방식으로 개선할 수 있었습니다.
2. 바이트 단위의 직렬화 및 역 직렬화
- 일반적으로 네트워크 통신 효율화를 위해 JSON, XML과 같은 최소한의 가독성을 보유하는 데이터 포맷을 사용하는 대신 프로토콜 버퍼(Protocol buffer)나 메시지 팩(Message pack)과 같은 바이트(Byte) 단위로 직렬화, 역 직렬화하여 전송되는 데이터의 양을 최소화하는 방법을 사용합니다.
- 저희는 내부적으로 프로토콜 버퍼와 메시지 팩을 모두 활용하고 있었지만, 추론 서버와의 통신에서는 비교적 적은 양의 데이터를 직렬화할 때 조금 더 속도가 빠른 메시지 팩을 활용하여 추론 속도를 개선했습니다.
c. 모델 배포 안정화
TorchServe는 모델 추론 서빙 프레임워크로 새로 학습된 모델을 배포하여 교체하기 위한 여러 management API를 제공합니다. 하지만 management API를 이용해서 새로운 모델을 배포하면 일정 시간 동안 추론 시간이 불안정하게 증가하는 문제가 발생했습니다.
저희는 이 문제를 해결하기 위해, 등록된 모델을 기본 모델로 변경하기 전에 일정 시간 warm-up을 진행했고, 초기 추론 속도가 느린 문제를 어느 정도 완화할 수 있습니다. 그럼에도 불구하고 문제를 완전히 해결할 수 없었고, 아래와 같이 프로파일링 옵션을 비활성화하여 모델을 교체한 직후에도 지연 시간이 증가하지 않고 안정적으로 모델을 배포할 수 있게 되었습니다.
torch._C._jit_set_profiling_mode(False)
torch._C._jit_set_profiling_executor(False)
2. 모델 경량화
별도의 추론 서버를 분리하면 네트워크 통신으로 인해 추가적인 지연 시간이 발생하기 때문에 모델 자체의 추론 시간을 최소화할 필요가 있었습니다. 저희는 아래의 3가지 경량화 기법을 활용하여 최소화를 진행하였습니다.
- 가지치기(Pruning)
- 불필요한 매개변수 또는 가중치를 제거하여 모델을 최적화하는 방법입니다. 학습된 모델에서 가중치가 작은 연결 또는 중요하지 않은 연결을 제거하여 더 효율적으로 메모리를 사용하고 실행 속도를 높일 수 있습니다.
2. 양자화(Quantization)
- 실수 범위의 값을 정수 범위의 값으로 변환하는 방법입니다. 일반적으로 실수 범위의 값은 FP32(32 비트), 정수 범위의 값은 INT8(8 비트)을 사용하는데, 줄어드는 비트만큼 전체 메모리 사용량 및 수행 속도가 감소하는 효과를 얻을 수 있습니다.
- 또한, CPU에서 정수형 연산이 최적화되어있기 때문에 조금 더 성능 향상을 꾀할 수 있습니다.
3. 지식 증류(Knowledge distillation)
- 지식 증류(Knowledge distillation)는 커다란 교사 모델(Teacher model)을 조그마한 학생 모델(Student model)로 학습한 지식을 전달하는 방법입니다.
- 이 기술은 주로 교사 모델의 지식을 학생 모델로 전달하여 더 경량화된 모델을 만들거나, 정확도를 높이는 데 사용됩니다.
아쉽게도 모든 방법이 성능 향상에 도움을 주지는 못했고, 추천 모델마다 모델 아키택쳐 특성이 달라 특정 모델에서 성능 향상을 보였던 방법이 다른 방법에서는 효과를 보지 못하거나 반영할 수 없기도 했습니다. 또한 추론 속도를 향상시킬 수는 있었으나 모델 정확도가 떨어질 수 있어 하이퍼 파라미터 조정을 같이 염두하며 경량화를 진행했습니다. 결과적으로는 모델마다 상이하지만 최대로 경량화된 모델을 기준으로 오프라인 정확도 감소를 1% 미만으로 최소화하면서도 모델 추론 속도를 50%가량 향상시킬 수 있었습니다.
추론 서버 모니터링
추천 서비스에 모니터링 시스템이 필요하듯이 독립된 추론 서버를 구성하면 이를 위한 별도의 모니터링 시스템이 필요합니다. 저희는 전사적으로 Datadog을 사용하여 여러 지표를 확인하고 있기 때문에 Datadog과 바로 연동할 수 있는 TorchServe를 서빙 프레임워크로 선택하기도 했습니다. TorchServe는 metrics_mode 옵션을 통해 프로메테우스(Prometheus) 형태의 메트릭 로그를 쌓을 수 있고, Datadog에서는 서비스 내 로그 파일 경로만 지정하면 간단하게 여러 지표를 그래프로 표현해주었습니다.
이 외에도 위 그림처럼 쿠버네티스 관련 메트릭이나, 각 모델의 추론 속도, 서비스 로그 등을 대시보드화 하여 만들 수 있어 서버 관련 지표를 한눈에 살펴볼 수 있습니다. 또한, 서비스 자체의 서비스 수준 목표(Service Level Objectives)를 지정하여 이를 벗어나는 지표를 보이는 경우 알림을 주어 문제 상황에 빠르게 대응할 수 있도록 했습니다.
또한, 학습된 추천 모델을 이용하여 실 서비스에 추천한 결과가 얼마나 효과적이었는지 판단할 수 있게 온라인 지표를 볼 수 있는 방법도 필요합니다. 저희는 기존부터 Google Looker studio에 실 서비스에서 쌓인 로그 데이터를 가공하여 노출 대비 클릭, 평가, 감상 등과 같은 주요 지표를 확인했으며, 국가, 구독/비구독 여부, 클라이언트 타입 등에 따라 각 지표를 필터링하여 지속적인 성능 모니터링을 해왔습니다.
결과적으로 두 가지 대시보드를 통해 추론 서비스의 서버 지표와 추천 모델의 온라인 지표를 확인할 수 있게 되었고, 학습된 모델이 실 서비스에서 잘 활용되고 있는지 판단할 수 있는 근거로 활용할 수 있게 되었습니다.
현재 왓챠 추천 시스템 구조
현재 왓챠/왓챠피디아 추천 시스템의 구조는 추론 서버를 분리한 후 위 그림과 같은 구조로 변경되었습니다.
우선, 추론 서버를 분리하여 모델 추론에 대한 작업만 관리할 수 있는 별도의 서비스를 구축하게 되어 모델의 문제가 API 서비스로 전이되지 않게 되었습니다. 또한, JNI의 의존성이 제거되어 독립적으로 최신 PyTorch 버전 업데이트를 할 수 있게 되었고 필요한 정보를 모델에 직접 주입하여 JNI로 인한 제약에서 벗어날 수 있게 되었습니다.
이러한 개선 이외에도, 분리된 추론 서버에 범용적인 인터페이스를 제공하여 추천 서비스 이외에 추론 결과를 필요로 하는 모든 서비스에서 활용할 수 있는 구조를 만들 수 있었습니다. 일반적으로 학습된 모델의 입력 값과 출력값은 모델에 맞게 변환된 값이므로 실제 데이터를 바로 모델 추론에 활용할 수 있도록 변환하는 과정이 필요합니다. 예를 들어, 콘텐츠마다 임의의 ID를 부여하거나, 텍스트를 토크나이저(Tokenizer)를 통해 임의의 양수 값의 순서로 변환하는 과정이 필요할 수 있습니다. 이러한 전후 처리는 학습된 모델에 의존성을 가지고 있기 때문에 모델 별로 관리되어야 했습니다.
다만, 학습된 추천 모델은 왓챠/왓챠피디아 서비스에서 활용할 뿐 아니라 다른 서비스에서 모델 추론 결과를 활용하는 경우(예를 들어, 사용자별 다음 시청 확률을 개인화 추천 푸시에 활용하거나 예상 별점을 콘텐츠 수급에 활용)가 있었습니다. 이를 위해 각 서비스에서 중복되는 전후 처리를 구현해야 하는 번거로움이 있었습니다.
결과적으로 위 그림처럼 추론 서버 내 핸들러를 통해 전후 처리를 진행하게 되어 추천 서비스뿐 아니라 추론 결과를 원하는 다른 서비스에서도 전후 처리에 대한 고려 없이 쉽게 추론 결과를 받아볼 수 있는 구조를 마련하게 되었습니다.
마치며
지금까지 왓챠 ML팀이 왓챠/왓챠피디아 추천 시스템에 MLOps를 어떻게 적용했는지 살펴보았습니다.
결론적으로 왓챠 ML 팀은 별도의 GPU 서버에서 쿠버네티스와 Argo workflow를 활용하여 ML 파이프라인을 구성하고, TorchServe로 독립적인 추론 서버를 구축하여 학습된 모델을 다양한 서비스에 활용할 수 있는 End-to-end 파이프라인을 만들 수 있게 되었습니다.
마지막으로 그동안 왓챠 추천 서비스에 MLOps를 적용하기 위해 같이 고생해 주신 폴, 매튜, 루이스, 제이크, 로기, 그리고 블로그 글 발행에 도움 주신 대런, 제인에게도 감사의 말을 전합니다.
감사합니다.