모델 서빙 (TorchServe, Triton, vLLM)¶
학습된 ML 모델을 프로덕션 환경에서 실시간으로 추론 요청에 응답할 수 있도록 배포하고 운영하는 기술.
1. 모델 서빙이 필요한 이유¶
1.1 Jupyter Notebook에서 프로덕션으로¶
| 문제 | Notebook | 프로덕션 서빙 |
|---|---|---|
| 확장성 | 단일 요청 | 초당 수천 요청 |
| 가용성 | 수동 실행 | 24/7 자동 |
| 지연시간 | 초 단위 | 밀리초 단위 |
| 리소스 | 비효율적 | GPU 최적화 |
| 버전 관리 | 없음 | A/B 테스트, Canary |
1.2 서빙 솔루션 선택 기준¶
| 솔루션 | 최적 사용 사례 | 장점 | 단점 |
|---|---|---|---|
| TorchServe | PyTorch 모델 빠른 배포 | 간단한 설정, 공식 지원 | 성능 최적화 한계 |
| Triton | 다양한 모델 통합 운영 | 고성능, 멀티 프레임워크 | 설정 복잡 |
| vLLM | LLM 대규모 서빙 | PagedAttention, 높은 처리량 | LLM 전용 |
1.3 서빙 아키텍처 개요¶
2. TorchServe¶
PyTorch 모델을 위한 경량 서빙 프레임워크. PyTorch 팀에서 공식 지원.
2.1 설치 및 기본 구조¶
# 설치
pip install torchserve torch-model-archiver torch-workflow-archiver
# 프로젝트 구조
model_store/
├── model_archive/
│ └── resnet50.mar # Model Archive
├── config/
│ └── config.properties # 서버 설정
└── handlers/
└── custom_handler.py # 커스텀 핸들러
2.2 모델 아카이브 생성 (.mar)¶
# 기본 아카이브 생성
torch-model-archiver \
--model-name resnet50 \
--version 1.0 \
--model-file model.py \
--serialized-file resnet50.pth \
--handler image_classifier \
--export-path model_store/
# 커스텀 핸들러 포함
torch-model-archiver \
--model-name custom_model \
--version 1.0 \
--serialized-file model.pt \
--handler handlers/custom_handler.py \
--extra-files index_to_name.json,config.yaml \
--export-path model_store/
2.3 커스텀 핸들러 구현¶
# handlers/custom_handler.py
import torch
import json
import logging
from ts.torch_handler.base_handler import BaseHandler
logger = logging.getLogger(__name__)
class CustomHandler(BaseHandler):
def __init__(self):
super().__init__()
self.initialized = False
def initialize(self, context):
"""모델 초기화"""
self.manifest = context.manifest
properties = context.system_properties
model_dir = properties.get("model_dir")
# 설정 파일 로드
with open(f"{model_dir}/config.yaml") as f:
self.config = yaml.safe_load(f)
# 모델 로드
serialized_file = self.manifest["model"]["serializedFile"]
model_path = f"{model_dir}/{serialized_file}"
self.device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
self.model = torch.jit.load(model_path, map_location=self.device)
self.model.eval()
# 레이블 매핑
mapping_file = f"{model_dir}/index_to_name.json"
with open(mapping_file) as f:
self.mapping = json.load(f)
self.initialized = True
logger.info("Model initialized successfully")
def preprocess(self, data):
"""입력 데이터 전처리"""
import torchvision.transforms as transforms
from PIL import Image
import io
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]
)
])
images = []
for row in data:
image = row.get("data") or row.get("body")
if isinstance(image, (bytes, bytearray)):
image = Image.open(io.BytesIO(image)).convert("RGB")
images.append(transform(image))
return torch.stack(images).to(self.device)
def inference(self, data):
"""모델 추론"""
with torch.no_grad():
outputs = self.model(data)
probabilities = torch.nn.functional.softmax(outputs, dim=1)
return probabilities
def postprocess(self, data):
"""결과 후처리"""
results = []
for prob in data:
top5_prob, top5_idx = torch.topk(prob, 5)
result = {
"predictions": [
{
"class": self.mapping[str(idx.item())],
"probability": prob.item()
}
for idx, prob in zip(top5_idx, top5_prob)
]
}
results.append(result)
return results
2.4 서버 설정¶
# config/config.properties
inference_address=http://0.0.0.0:8080
management_address=http://0.0.0.0:8081
metrics_address=http://0.0.0.0:8082
# 모델 설정
load_models=all
model_store=/home/model-server/model-store
# 워커 설정
default_workers_per_model=4
job_queue_size=100
# GPU 설정
number_of_gpu=2
# 배칭 설정
batch_size=8
max_batch_delay=100
# 메모리 관리
vmargs=-Xmx4g -XX:+UseG1GC
# 로깅
async_logging=true
2.5 서버 실행 및 API¶
# 서버 시작
torchserve --start \
--model-store model_store \
--models resnet50=resnet50.mar \
--ts-config config/config.properties
# 추론 요청
curl -X POST http://localhost:8080/predictions/resnet50 \
-T image.jpg
# 모델 관리 API
# 모델 등록
curl -X POST "http://localhost:8081/models?url=resnet50.mar"
# 모델 스케일링
curl -X PUT "http://localhost:8081/models/resnet50?min_worker=2&max_worker=4"
# 모델 상태 확인
curl http://localhost:8081/models/resnet50
# 모델 제거
curl -X DELETE http://localhost:8081/models/resnet50
# 서버 중지
torchserve --stop
2.6 동적 배칭 설정¶
# models/resnet50/config.json
{
"batchSize": 8,
"maxBatchDelay": 100,
"responseTimeout": 120,
"maxRetryTimeoutInSec": 5
}
2.7 트러블슈팅¶
| 문제 | 원인 | 해결책 |
|---|---|---|
| OOM 오류 | 배치 크기 과다 | batch_size 줄이기, number_of_gpu 조정 |
| 느린 응답 | 워커 부족 | default_workers_per_model 증가 |
| 모델 로드 실패 | 의존성 누락 | requirements.txt를 --extra-files에 포함 |
| CUDA 오류 | GPU 드라이버 불일치 | Docker 이미지 버전 확인 |
3. NVIDIA Triton Inference Server¶
멀티 프레임워크 지원, 고성능 추론 서버. TensorRT, ONNX, PyTorch, TensorFlow 등 지원.
3.1 모델 저장소 구조¶
3.2 모델 설정 (config.pbtxt)¶
# model_repository/text_classifier/config.pbtxt
name: "text_classifier"
platform: "onnxruntime_onnx"
max_batch_size: 64
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ -1 ] # 동적 크기
},
{
name: "attention_mask"
data_type: TYPE_INT64
dims: [ -1 ]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [ -1 ]
}
]
# 동적 배칭
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ]
max_queue_delay_microseconds: 100000
}
# 인스턴스 그룹 (GPU 배치)
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0, 1 ]
}
]
# 버전 정책
version_policy: { latest: { num_versions: 2 }}
# 최적화
optimization {
execution_accelerators {
gpu_execution_accelerator : [
{ name : "tensorrt" }
]
}
}
3.3 PyTorch 모델을 ONNX로 변환¶
import torch
import torch.onnx
# 모델 로드
model = torch.load("model.pt")
model.eval()
# 더미 입력
dummy_input = torch.randn(1, 3, 224, 224)
# ONNX 내보내기
torch.onnx.export(
model,
dummy_input,
"model_repository/image_classifier/1/model.onnx",
input_names=["image"],
output_names=["prediction"],
dynamic_axes={
"image": {0: "batch_size"},
"prediction": {0: "batch_size"}
},
opset_version=17,
)
3.4 서버 실행 (Docker)¶
# Triton 서버 실행
docker run --gpus all --rm -p 8000:8000 -p 8001:8001 -p 8002:8002 \
-v $(pwd)/model_repository:/models \
nvcr.io/nvidia/tritonserver:24.01-py3 \
tritonserver --model-repository=/models
# 포트 설명:
# 8000: HTTP
# 8001: gRPC
# 8002: Metrics (Prometheus)
3.5 클라이언트 코드¶
import tritonclient.http as httpclient
import tritonclient.grpc as grpcclient
import numpy as np
# HTTP 클라이언트
def predict_http(input_data):
client = httpclient.InferenceServerClient(url="localhost:8000")
# 입력 준비
inputs = [
httpclient.InferInput("input_ids", input_data.shape, "INT64"),
]
inputs[0].set_data_from_numpy(input_data)
# 출력 설정
outputs = [
httpclient.InferRequestedOutput("logits")
]
# 추론 요청
response = client.infer(
model_name="text_classifier",
model_version="1",
inputs=inputs,
outputs=outputs,
)
return response.as_numpy("logits")
# gRPC 클라이언트 (더 빠름)
def predict_grpc(input_data):
client = grpcclient.InferenceServerClient(url="localhost:8001")
inputs = [
grpcclient.InferInput("input_ids", input_data.shape, "INT64"),
]
inputs[0].set_data_from_numpy(input_data)
outputs = [
grpcclient.InferRequestedOutput("logits")
]
response = client.infer(
model_name="text_classifier",
inputs=inputs,
outputs=outputs,
)
return response.as_numpy("logits")
# 비동기 요청
async def predict_async(input_data):
client = httpclient.InferenceServerClient(url="localhost:8000")
inputs = [httpclient.InferInput("input_ids", input_data.shape, "INT64")]
inputs[0].set_data_from_numpy(input_data)
# 비동기 추론
async_request = client.async_infer(
model_name="text_classifier",
inputs=inputs,
)
# 결과 대기
response = async_request.get_result()
return response.as_numpy("logits")
3.6 앙상블 모델¶
# model_repository/ensemble_pipeline/config.pbtxt
name: "ensemble_pipeline"
platform: "ensemble"
max_batch_size: 32
input [
{
name: "raw_text"
data_type: TYPE_STRING
dims: [ -1 ]
}
]
output [
{
name: "sentiment"
data_type: TYPE_FP32
dims: [ 3 ]
}
]
ensemble_scheduling {
step [
{
model_name: "tokenizer"
model_version: 1
input_map {
key: "text"
value: "raw_text"
}
output_map {
key: "input_ids"
value: "tokenized_ids"
}
},
{
model_name: "text_classifier"
model_version: 1
input_map {
key: "input_ids"
value: "tokenized_ids"
}
output_map {
key: "logits"
value: "sentiment"
}
}
]
}
3.7 Python Backend (커스텀 로직)¶
# model_repository/tokenizer/1/model.py
import triton_python_backend_utils as pb_utils
import numpy as np
from transformers import AutoTokenizer
class TritonPythonModel:
def initialize(self, args):
self.tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
def execute(self, requests):
responses = []
for request in requests:
# 입력 가져오기
text = pb_utils.get_input_tensor_by_name(request, "text")
text = text.as_numpy()[0].decode("utf-8")
# 토크나이징
encoded = self.tokenizer(
text,
padding="max_length",
truncation=True,
max_length=512,
return_tensors="np"
)
# 출력 텐서 생성
output_tensor = pb_utils.Tensor(
"input_ids",
encoded["input_ids"].astype(np.int64)
)
responses.append(pb_utils.InferenceResponse([output_tensor]))
return responses
def finalize(self):
pass
3.8 모니터링 및 메트릭¶
주요 메트릭:
- nv_inference_request_success: 성공한 요청 수
- nv_inference_request_failure: 실패한 요청 수
- nv_inference_queue_duration_us: 큐 대기 시간
- nv_inference_compute_infer_duration_us: 추론 시간
- nv_gpu_utilization: GPU 사용률
3.9 트러블슈팅¶
| 문제 | 원인 | 해결책 |
|---|---|---|
| 모델 로드 실패 | config.pbtxt 오류 | input/output dims 확인 |
| 배칭 비효율 | 배치 크기 미최적화 | preferred_batch_size 조정 |
| GPU 메모리 부족 | 인스턴스 과다 | instance_group count 감소 |
| 느린 첫 요청 | 모델 warm-up 없음 | --model-control-mode=explicit |
4. vLLM¶
LLM 특화 추론 엔진. PagedAttention으로 메모리 효율 극대화, 연속 배칭으로 처리량 향상.
4.1 핵심 기술¶
PagedAttention:
- KV Cache를 페이지 단위로 관리
- 메모리 단편화 방지
- 메모리 효율 최대 24배 향상
Continuous Batching:
- 요청별 완료 시 즉시 새 요청 추가
- 기존 배칭 대비 처리량 2-4배 향상
+------------------+ +------------------+
| Static Batching | | Continuous Batch |
+------------------+ +------------------+
| Req1: ████████ | | Req1: ████ |
| Req2: ████████ | | Req2: ████████ |
| Req3: ████████ | | Req3: █████ |
| (대기) | | Req4: ████████ | <- 즉시 추가
+------------------+ +------------------+
4.2 설치 및 기본 사용¶
# 설치
pip install vllm
# 기본 추론
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--port 8000
4.3 Python API¶
from vllm import LLM, SamplingParams
# 모델 로드
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
tensor_parallel_size=2, # 2 GPU 사용
gpu_memory_utilization=0.9,
max_model_len=8192,
)
# 샘플링 파라미터
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.9,
max_tokens=512,
stop=["</s>", "[/INST]"],
)
# 배치 추론
prompts = [
"Explain quantum computing in simple terms:",
"Write a Python function to calculate fibonacci:",
"What are the benefits of exercise?",
]
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
prompt = output.prompt
generated_text = output.outputs[0].text
print(f"Prompt: {prompt[:50]}...")
print(f"Generated: {generated_text}")
print("-" * 50)
4.4 OpenAI 호환 API 서버¶
# 서버 시작 (OpenAI API 호환)
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--api-key "your-api-key" \
--host 0.0.0.0 \
--port 8000 \
--tensor-parallel-size 2 \
--max-model-len 8192 \
--gpu-memory-utilization 0.9
# 클라이언트 코드 (OpenAI SDK 사용)
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="your-api-key",
)
# Chat Completion
response = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Explain machine learning."},
],
temperature=0.7,
max_tokens=512,
)
print(response.choices[0].message.content)
# Streaming
stream = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[
{"role": "user", "content": "Write a story about a robot."},
],
stream=True,
)
for chunk in stream:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
4.5 고급 설정¶
from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest
# 양자화 모델 사용
llm = LLM(
model="TheBloke/Llama-2-7B-GPTQ",
quantization="gptq",
dtype="float16",
)
# AWQ 양자화
llm = LLM(
model="TheBloke/Llama-2-7B-AWQ",
quantization="awq",
)
# LoRA 어댑터
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
enable_lora=True,
max_loras=4,
)
outputs = llm.generate(
prompts,
sampling_params,
lora_request=LoRARequest("adapter1", 1, "/path/to/lora"),
)
4.6 프로덕션 설정¶
# 프로덕션 서버 설정
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-70B-Instruct \
--tensor-parallel-size 4 \
--pipeline-parallel-size 2 \
--max-model-len 32768 \
--gpu-memory-utilization 0.95 \
--max-num-seqs 256 \
--max-num-batched-tokens 32768 \
--enable-chunked-prefill \
--disable-log-requests \
--uvicorn-log-level warning
주요 파라미터: | 파라미터 | 설명 | 권장값 | |----------|------|--------| | tensor-parallel-size | GPU 분산 수 | GPU 수 | | gpu-memory-utilization | GPU 메모리 사용률 | 0.9-0.95 | | max-num-seqs | 동시 요청 수 | 256 | | max-model-len | 최대 컨텍스트 길이 | 모델별 상이 | | enable-chunked-prefill | 프리필 청킹 | True (메모리 절약) |
4.7 Docker 배포¶
# Dockerfile
FROM vllm/vllm-openai:latest
ENV MODEL_NAME="meta-llama/Llama-3.1-8B-Instruct"
ENV TENSOR_PARALLEL_SIZE=2
ENV GPU_MEMORY_UTILIZATION=0.9
EXPOSE 8000
CMD python -m vllm.entrypoints.openai.api_server \
--model $MODEL_NAME \
--tensor-parallel-size $TENSOR_PARALLEL_SIZE \
--gpu-memory-utilization $GPU_MEMORY_UTILIZATION \
--host 0.0.0.0 \
--port 8000
# docker-compose.yml
version: '3.8'
services:
vllm:
image: vllm/vllm-openai:latest
runtime: nvidia
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 2
capabilities: [gpu]
ports:
- "8000:8000"
environment:
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
command: >
--model meta-llama/Llama-3.1-8B-Instruct
--tensor-parallel-size 2
--gpu-memory-utilization 0.9
volumes:
- ./model_cache:/root/.cache/huggingface
4.8 성능 벤치마크¶
# 벤치마크 스크립트
import time
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="test")
async def benchmark(num_requests=100, concurrency=10):
prompts = ["Explain AI in one paragraph."] * num_requests
async def single_request(prompt):
start = time.time()
response = await client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": prompt}],
max_tokens=100,
)
return time.time() - start, len(response.choices[0].message.content)
semaphore = asyncio.Semaphore(concurrency)
async def limited_request(prompt):
async with semaphore:
return await single_request(prompt)
start_time = time.time()
results = await asyncio.gather(*[limited_request(p) for p in prompts])
total_time = time.time() - start_time
latencies = [r[0] for r in results]
tokens = sum(r[1] for r in results)
print(f"Total requests: {num_requests}")
print(f"Total time: {total_time:.2f}s")
print(f"Throughput: {num_requests/total_time:.2f} req/s")
print(f"Tokens/sec: {tokens/total_time:.2f}")
print(f"Avg latency: {sum(latencies)/len(latencies):.3f}s")
print(f"P99 latency: {sorted(latencies)[int(len(latencies)*0.99)]:.3f}s")
asyncio.run(benchmark())
4.9 트러블슈팅¶
| 문제 | 원인 | 해결책 |
|---|---|---|
| CUDA OOM | 모델 크기 초과 | tensor-parallel-size 증가, 양자화 사용 |
| 느린 첫 요청 | 모델 컴파일 | --enforce-eager 비활성화 |
| 토큰 제한 오류 | max_model_len 초과 | --max-model-len 조정 |
| 낮은 처리량 | 배칭 비효율 | max-num-batched-tokens 증가 |
5. 서빙 솔루션 비교¶
5.1 기능 비교¶
| 기능 | TorchServe | Triton | vLLM |
|---|---|---|---|
| 프레임워크 | PyTorch | 다중 | LLM 특화 |
| 동적 배칭 | O | O | O (연속) |
| GPU 최적화 | 기본 | TensorRT | PagedAttention |
| 멀티 GPU | O | O | O (자동) |
| 모델 버저닝 | O | O | X |
| A/B 테스트 | 제한적 | O | X |
| 스트리밍 | X | O | O |
| 양자화 | 제한적 | O | O (GPTQ, AWQ) |
5.2 사용 시나리오¶
TorchServe 선택:
- PyTorch 모델 빠른 배포
- 간단한 설정 원할 때
- 프로토타이핑
Triton 선택:
- 멀티 프레임워크 환경
- 엔터프라이즈 기능 필요
- 복잡한 파이프라인 (앙상블)
- 최고 성능 필요
vLLM 선택:
- LLM 서빙 (7B+ 모델)
- 높은 처리량 필요
- OpenAI API 호환 필요
- 메모리 효율 중요
6. Kubernetes 배포¶
6.1 TorchServe Deployment¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: torchserve
spec:
replicas: 2
selector:
matchLabels:
app: torchserve
template:
metadata:
labels:
app: torchserve
spec:
containers:
- name: torchserve
image: pytorch/torchserve:latest-gpu
ports:
- containerPort: 8080
- containerPort: 8081
resources:
limits:
nvidia.com/gpu: 1
volumeMounts:
- name: models
mountPath: /home/model-server/model-store
volumes:
- name: models
persistentVolumeClaim:
claimName: model-pvc
---
apiVersion: v1
kind: Service
metadata:
name: torchserve
spec:
selector:
app: torchserve
ports:
- name: inference
port: 8080
- name: management
port: 8081
6.2 vLLM with HPA¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-server
spec:
replicas: 1
selector:
matchLabels:
app: vllm
template:
metadata:
labels:
app: vllm
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args:
- "--model"
- "meta-llama/Llama-3.1-8B-Instruct"
- "--tensor-parallel-size"
- "2"
ports:
- containerPort: 8000
resources:
limits:
nvidia.com/gpu: 2
env:
- name: HUGGING_FACE_HUB_TOKEN
valueFrom:
secretKeyRef:
name: hf-secret
key: token
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-server
minReplicas: 1
maxReplicas: 4
metrics:
- type: Pods
pods:
metric:
name: gpu_utilization
target:
type: AverageValue
averageValue: "80"
7. 모범 사례¶
7.1 서빙 체크리스트¶
배포 전:
[ ] 모델 양자화/최적화 적용
[ ] 입출력 스키마 검증
[ ] 로드 테스트 완료
[ ] 롤백 계획 수립
운영 중:
[ ] 지연시간 모니터링 (P50, P95, P99)
[ ] 처리량 모니터링
[ ] GPU 사용률 추적
[ ] 오류율 알림 설정
스케일링:
[ ] HPA 설정
[ ] 예측 기반 스케일링
[ ] GPU 비용 최적화
7.2 성능 최적화 가이드¶
1. 모델 최적화
- TensorRT 변환
- 양자화 (INT8, FP16)
- 레이어 퓨전
2. 배칭 최적화
- 동적 배칭 활성화
- 최적 배치 크기 탐색
- 큐 타임아웃 조정
3. 인프라 최적화
- GPU 인스턴스 선택
- 멀티 GPU 분산
- 네트워크 지연 최소화