코드로 이해하는 BentoML

Dongjun Lee
LBOX Team
Published in
17 min readJul 2, 2023

최초 글은 2023년 2월 22일 발행이 되었습니다.

안녕하세요, LBox ML Engineer 이동준입니다.

이번 글에서는 엘박스에서 Model Serving에 사용하고 있는 BentoML에 대해서 다뤄보겠습니다. BentoML은 서빙의 편의성에 특화되어 있는 프레임워크로 Github 상에서 많은 Star(약 3천 개)를 받은 프로젝트입니다.

Model Serving

먼저 BentoML을 다루기 전에 Model Serving에 대해서 간략하게 이야기를 해보고자 합니다. 머신러닝으로 학습한 모델을 배포하는 과정에는 다음과 같은 특징이 있습니다.

  • 일반적으로 모델이 무겁고, 추론 과정에도 많은 연산이 들어간다. 서비스에 따라서 하드웨어가 달라지기도 합니다. (CPU/GPU)
  • 데이터의 변화에 맞춰서 모델을 재학습 후 배포하는 과정이 필요하다.

이러한 특징들로 인해서 머신러닝 모델은 기존의 서비스에 포함이 되는 것보다는 독립적으로 배포되곤 합니다. 또한 데이터의 변화 주기가 빠른 경우에 대비하여 모델을 쉽게 교체할 수 있어야 합니다.

배포 이외에 Model Serving이 사용되는 방식에는 다음 대표적인 2가지 방식이 있습니다. online batching과 offline batching의 방식입니다.

(출처 :CS 329S Lecture2: Designing an ML system )

BentoML은 이 Model Serving에 필요한 기본적인 특징들(독립적인 배포, online/offline batch)을 모두 지원하는 오픈소스입니다. 이제 BentoML에 대해서 자세히 알아보도록 하겠습니다.

BentoML

출처: https://github.com/bentoml/BentoML

BentoML은 ‘Model Serving Made Easy’ 라는 슬로건을 사용하는 것처럼, 간단하게 다양한 ML 프레임워크를 서빙할 수 있는 편의성에 기능이 집중되어 있습니다.

많이 사용되는 프레임워크인 Scikit-Learn, PyTorch, Tensorflow2뿐만 아니라 널리 사용되는 딥러닝 라이브러리들(e.g. Transformers, Pytorch Lightning, Detectron 등) 역시 지원을 하고 있습니다.

BentoML 사용법

모델을 서빙하는 방식은 간단합니다. 예시로 언어모델 GPT-2를 서빙하는 코드를 살펴보겠습니다.

# gpt2_service.py
import bentoml
from transformers import AutoModelWithLMHead, AutoTokenizer
from bentoml.adapters import JsonInput
@bentoml.env(pip_packages=["transformers==3.1.0", "torch==1.6.0"])
@bentoml.artifacts([TransformersModelArtifact("gptModel")])
class TransformerService(bentoml.BentoService):
@bentoml.api(input=JsonInput(), batch=False)
def predict(self, parsed_json):
src_text = parsed_json.get("text")
model = self.artifacts.gptModel.get("model")
tokenizer = self.artifacts.gptModel.get("tokenizer")
input_ids = tokenizer.encode(src_text, return_tensors="pt")
output = model.generate(input_ids, max_length=50)
output = tokenizer.decode(output[0], skip_special_tokens=True)
return output
ts = TransformerService()
model_name = "gpt2"
model = AutoModelWithLMHead.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
artifact = {"model": model, "tokenizer": tokenizer}
ts.pack("gptModel", artifact)

위와 같이 코드를 작성하고 몇 가지 커맨드만 입력하면, 바로 GPT-2로 텍스트를 생성하는 모델을 서빙할 수 있게 됩니다. 위 코드에서 파악할 수 있는 특징들은 다음과 같습니다.

  • @bentoml.env 를 통해서 필요한 package 정의가 가능하다.
  • 요청을 받아서 추론을 하는 predict 함수만 작성하면 된다.
  • 사용이 되는 Model, Tokenizer 등은 pack 으로 셋팅이 필요하다.

이렇게 작성된 TransformerService 를 배포해보겠습니다. 간단하죠?

python gpt2_service.py
bentoml serve TransformerService:latest
>>> bash
* Serving Flask app "TransformerService" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on <http://127.0.0.1:63789/> (Press CTRL+C to quit)

이제 본격적으로 BentoML이 어떻게 모델을 서빙하는지 살펴보도록 하겠습니다.

BentoML Architecture

먼저 대표적인 Model Serving 프레임워크인 TorchServe, TensorFlow Serving 각각의 Architecture를 간단히 살펴보고 BentoML을 보면 이해가 더 쉬울 것 같습니다.

출처: https://github.com/pytorch/serve
출처 : https://www.tensorflow.org/tfx/serving/architecture

두 프레임워크가 비슷한 구조를 보이는 것을 알 수 있는데, 바로 학습한 모델이 추론을 하는 모듈과 Client로부터 요청을 받아서 모델로 이 요청들을 전달하는 모듈 두가지가 존재한다는 것입니다.

  • TorchServe: 모델 Inference 용 Model Thread(Worker process) / 요청을 전달하는 request batching
  • Tensorflow Serving : 모델을 로드하는 Loader / 요청을 전달하는 ServableHandle

그러면 이 구조를 염두에 두고 BentoML의 Architecture를 살펴보겠습니다.

코드는 ‘0.13-LTS’ 브랜치를 기준으로 이야기합니다.

# Start a production API server serving specified BentoService
bentoml serve-gunicorn TransformerService:latest

위 명령어는 Production 환경에 모델을 서빙하는 경우에 사용됩니다. 이 명령어가 연결되는 코드를 살펴보면 다음과 같습니다.

# <https://github.com/bentoml/BentoML/blob/0.13-LTS/bentoml/server/__init__.py#L70>
def start_prod_server(...):
...
# BentoMLContainer에 필요한 설정들을 셋팅 (Port, Timeout, Worker 수 등)
bento_server = BentoMLContainer.config.bento_server
bento_server.port.set(port or skip)
bento_server.timeout.set(timeout or skip)
bento_server.microbatch.timeout.set(timeout or skip)
bento_server.workers.set(workers or skip)
bento_server.swagger.enabled.set(enable_swagger or skip)
bento_server.microbatch.workers.set(microbatch_workers or skip)
bento_server.microbatch.max_batch_size.set(mb_max_batch_size or skip)
bento_server.microbatch.max_latency.set(mb_max_latency or skip)
BentoMLContainer.prometheus_lock.get() # generate lock before fork
BentoMLContainer.forward_port.get() # generate port before fork
import multiprocessing
# Multiprocessing 기반으로 Prod Server를 설정한 Worker 수 만큼 띄웁니다.
model_server_job = multiprocessing.Process(
target=_start_prod_server, args=(BentoMLContainer,), daemon=True
)
model_server_job.start()
try:
# 요청을 전달하는 Proxy Server 시작
_start_prod_proxy(BentoMLContainer)
finally:
model_server_job.terminate()

직관적으로 이해할 수 있도록 코드가 잘 작성되어 있기 때문에, 크게 proxy_servermodel_server_job 으로 구성되어 있다는 것을 알 수 있습니다.

Model Server Job (GunicornModelServer)

# <https://github.com/bentoml/BentoML/blob/0.13-LTS/bentoml/server/gunicorn_model_server.py>
from gunicorn.app.base import Application
class GunicornModelServer(Application):
...
@property
@inject
def app(self, app: "ModelApp" = Provide[BentoMLContainer.model_app]):
return app

Application 은 gunicorn 기반으로 돌릴 수 있는 WSGI application을 원하는 대로 세팅할 수 있도록 제공하는 기본 클래스입니다. 여기에 기존에 정의된 ModelApp 이 주입(inject) 됩니다.

Model App 은 예시에서 정의했던 BentoService 를 연결하여 Flask 기반의 REST API Server가 셋팅이 됩니다. Inference API를 기본으로, swagger, metrics 등의 부가적인 기능들도 같이 셋팅이 되도록 되어있습니다. (자세한 코드는 여기에서 확인이 가능합니다.)

Proxy Server (MarshalApp )

# <https://github.com/bentoml/BentoML/blob/0.13-LTS/bentoml/marshal/marshal.py#L354>
async def _batch_handler_template(...):
...
reqs_s = DataLoader.merge_requests(requests) # 들어온 요청들을 합치고,
...
client = self.get_client()
timeout = ClientTimeout(
total=(self.mb_max_latency or max_latency) // 1000
)
async with client.post(
api_url, data=reqs_s, headers=headers, timeout=timeout
) as resp:
raw = await resp.read() # 비동기로 Model App의 Inference API를 호출합니다.

# <https://github.com/bentoml/BentoML/blob/0.13-LTS/bentoml/marshal/marshal.py#L456>
def run(...)
...
loop = asyncio.new_event_loop() # Python의 코루틴을 가능하게 하는 EventLoop
asyncio.set_event_loop(loop)
app = self.get_app()
run_app(app, port=port)

코드에 대한 설명을 하기 전에 배경 지식에 대해서 간단하게 이야기해보려고 합니다. asyncio는 Python 3.5 버전부터 추가된 비동기 프로그래밍을 지원하는 라이브러리입니다. async/await 문법을 통해서 Coroutine 이 가능하게 되죠.

이 asyncio를 통한 Coroutine의 동작 방식을 설명하면 다음과 같습니다. Event Loop는 반복문을 계속해서 돌면서 Task에 제어권을 넘기고 await 를 만나면 응답이 올 때까지 기다리게 되고, 이때 다시 루프로 제어권이 돌아와서 다음 Task를 실행하게 됩니다. 이 과정을 계속 반복하기 때문에 하나의 스레드에서 동시에 여러 작업들을 수행할 수 있는 것이죠.

위의 MarshalApp 에 있는 코드로 연결하면 다음과 같습니다.

  • Event Loop 기반으로 비동기로 요청을 처리
  • 들어온 요청들을 모아서 ModelServer로 POST call로 작업을 전달

이제 위의 구조를 BentoML에서 정리한 도식으로 살펴보겠습니다.

출처: BentoML Documentation

사용자들의 요청이 marshal(proxy server)에서 모아지고 model server job으로 전달되는 것이죠.

이런 구조를 가짐으로써 가능해지는 것이 BentoML의 주요 특징 중의 하나인 ‘Adaptive Micro Batching’ 입니다. 들어온 요청을 Proxy에서 설정한 시간 동안 기다렸다가, 요청을 합쳐서 Model Server로 넘기는 것이죠.

예를 들어서, 아주 빠르게 주문이 들어오는 식당이 있다면 손님 A, B가 연달아서 제육볶음 메뉴를 주문할 수 있을 것입니다. 이때 받은 주문이 합쳐져서 요리사에게 들어가게 되고, 요리사가 2인분을 한꺼번에 요리하여 전달해주면, 각각의 접시에 담은 다음, 음식이 서빙이 되는 것과 같은 방식입니다.

아래 그림을 보면 좀 더 확실하게 이해가 될 것입니다.

출처: BentoML Documentation

이러한 구조를 사용함으로써, 리소스가 허락하는 선에서 여러 명의 요리사(Model Server Job)를 고용할 수 있게 되고, 많은 음식 주문들을 받아서 처리할 수 있게 됩니다. 즉, 처리량(Throughput)을 쉽게 올릴 수 있다는 특징이 있습니다.

타 오픈소스와의 비교

BentoML은 간단하게 모델을 서빙할 수 있다는 큰 장점이 있습니다. 하지만 이러한 ‘편의성’을 위해서 모든 코드가 Python으로 이루어진 만큼, 단점이 있습니다. 바로 속도 향상이 어렵다는 점입니다. (자세한 성능에 대해서는 Line Engineering 블로그 글을 참고해 보시면, 더 많은 정보를 얻을 수 있을 것입니다.)

바로 적당한 요리사는 쉽게 고용할 수 있으나, 각각의 요리사가 빠르게 요리를 만들게 하기에는 어려움이 있다는 것이죠. 예를 들어, 요리사 한 명이 하나의 요리를 만드는 데 걸리는 시간이 10초가 걸리게 된다면 아무리 요리사의 수를 늘려도 최소 10초 이상의 시간이 걸린다는 단점이 있습니다.

특히 대규모 언어모델과 같이 사이즈가 큰 모델의 경우, 모델의 사이즈가 점차 커지고 있는 입장에서는 BentoML을 통한 서빙에 어려움이 있을 것입니다. 예를 들어, Transformer에 특화된 오픈소스에는 FastTransformer, transformer-deploy 등의 경우, cuda 레벨에서 최적화를 하기도 하고, 정확도를 희생하면서 연산량을 배로 줄일 수 있는 Int4 Precision 등의 방식들을 적용하여 속도를 확 끌어올릴 수 있기 때문입니다.

몇 가지 다른 오픈소스들과 비교를 해보면 다음과 같습니다.

REST 기반으로 API 서버를 만들어주고, 요청을 모아서 처리하는 Batch 측면이나 모델의 성능을 확인할 수 있는 Metrics 등의 필수적인 기능들은 비슷하게 제공하는 것을 알 수 있습니다.

이외에 대표적인 차이점들은 TorchServe와 Triton Inference Server는 최적화된 모델의 배포 형식(PyTorch TorchScript, TensorRT 등)을 사용한다는 것입니다. 그리고 단순히 BentoService 코드를 작성해서 배포할 수 있는 BentoML과는 다르게 나머지 두 오픈소스는 세팅하고 빌드 및 모델을 따로 등록해서 사용하는 것 또한 필요합니다.

편의성과 적당한 속도가 필요한 경우에는 BentoML이 더 적합한 도구일 것이고, 조금 더 큰 스케일의 시스템에 빠른 속도가 요구되는 경우에는 TorchServe 혹은 Triton Inference Server가 더 좋은 대안이 될 것입니다.

끝으로

Model Serving에 사용되고 있는 많은 오픈소스 도구들은 각각의 뚜렷한 특징을 가지고 있습니다. 성공적인 Model Serving을 위해서는 이러한 특징들을 이해하고 상황에 맞춰서 필요한 도구를 사용하는 것이 무엇보다 중요할 것입니다.

이번 글에서는 현재 엘박스에서 사용하고 있는 BentoML에 대해서 더 깊게 이해하기 위해서 코드 레벨로 살펴보고, 다른 오픈소스와도 비교해보았습니다. 이 글이 모델 서빙을 고민하시는 분들에게 도움이 되었으면 좋겠습니다.

많은 피드백을 주신 황원석님, 이진님께 감사를 전합니다.

References

--

--