콘텐츠로 이동
Data Prep
상세

모델 서빙 (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 서빙 아키텍처 개요

Model Serving 아키텍처


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 모델 저장소 구조

serving diagram 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 모니터링 및 메트릭

# prometheus.yml
scrape_configs:
  - job_name: 'triton'
    static_configs:
      - targets: ['localhost:8002']

주요 메트릭: - 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 분산
   - 네트워크 지연 최소화

참고 자료