본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 7. · 17 Views
모델 서빙 최적화 완벽 가이드
머신러닝 모델을 실제 서비스에 배포할 때 꼭 알아야 할 최적화 기법들을 다룹니다. 배치 추론부터 TensorRT까지, 초급 개발자도 쉽게 따라할 수 있도록 실무 중심으로 설명합니다.
목차
1. 배치 추론 (Batching)
김개발 씨는 이미지 분류 API를 담당하고 있었습니다. 그런데 이상합니다.
사용자가 늘어날수록 서버 응답 시간이 급격히 느려지는 것이었습니다. GPU는 분명히 좋은 것을 쓰고 있는데 왜 이럴까요?
배치 추론은 여러 개의 요청을 모아서 한 번에 처리하는 기법입니다. 마치 엘리베이터가 한 명씩 태우지 않고 여러 명을 한꺼번에 태워 올라가는 것과 같습니다.
GPU는 병렬 처리에 최적화되어 있기 때문에, 하나씩 처리하면 오히려 비효율적입니다.
다음 코드를 살펴봅시다.
import torch
from queue import Queue
from threading import Thread, Event
import time
class BatchInferenceServer:
def __init__(self, model, batch_size=32, max_wait_time=0.1):
self.model = model
self.batch_size = batch_size
self.max_wait_time = max_wait_time # 최대 대기 시간 (초)
self.request_queue = Queue()
self.running = True
def process_batch(self):
# 배치가 찰 때까지 또는 최대 대기 시간까지 요청 수집
batch_inputs = []
batch_callbacks = []
start_time = time.time()
while len(batch_inputs) < self.batch_size:
elapsed = time.time() - start_time
if elapsed >= self.max_wait_time and batch_inputs:
break # 대기 시간 초과 시 현재 배치로 진행
try:
input_data, callback = self.request_queue.get(timeout=0.01)
batch_inputs.append(input_data)
batch_callbacks.append(callback)
except:
continue
if batch_inputs:
# 배치로 묶어서 한 번에 추론
batch_tensor = torch.stack(batch_inputs)
with torch.no_grad():
results = self.model(batch_tensor)
# 각 요청에 결과 반환
for i, callback in enumerate(batch_callbacks):
callback(results[i])
김개발 씨는 입사 6개월 차 ML 엔지니어입니다. 열심히 만든 이미지 분류 모델을 API로 배포했는데, 처음에는 잘 작동하던 서비스가 사용자가 늘면서 점점 느려지기 시작했습니다.
"이상하네요. GPU 사용률을 보니까 고작 30% 밖에 안 쓰고 있어요." 김개발 씨가 모니터를 보며 중얼거렸습니다.
선배 개발자 박시니어 씨가 다가와 상황을 살펴봅니다. "아, 요청을 하나씩 처리하고 있구나.
그러면 GPU가 놀 수밖에 없어요." 그렇다면 배치 추론이란 정확히 무엇일까요? 쉽게 비유하자면, 배치 추론은 마치 놀이공원의 롤러코스터와 같습니다.
롤러코스터는 한 명이 타자마자 바로 출발하지 않습니다. 좌석이 어느 정도 찰 때까지 기다렸다가 한 번에 출발합니다.
한 명씩 태워서 보내면 효율이 너무 떨어지기 때문입니다. GPU도 마찬가지입니다.
수천 개의 코어가 있는데, 이미지 하나를 처리하면 대부분의 코어가 놀게 됩니다. 배치 처리가 없던 시절에는 어땠을까요?
개발자들은 요청이 들어올 때마다 바로 모델에 넣어 결과를 반환했습니다. 코드는 단순하지만, GPU 활용률은 처참했습니다.
더 큰 문제는 트래픽이 몰릴 때였습니다. 요청 하나당 GPU 메모리를 할당하고 해제하는 오버헤드가 쌓이면서, 정작 추론보다 준비 작업에 더 많은 시간이 소요되었습니다.
바로 이런 문제를 해결하기 위해 배치 추론이 등장했습니다. 배치 추론을 사용하면 GPU의 병렬 처리 능력을 최대한 활용할 수 있습니다.
32개의 이미지를 하나씩 처리하는 것과 한 번에 처리하는 것은 시간 차이가 거의 없습니다. 무엇보다 처리량이 극적으로 증가한다는 큰 이점이 있습니다.
위의 코드를 살펴보겠습니다. 먼저 request_queue에 들어온 요청들을 모읍니다.
batch_size만큼 차거나 max_wait_time이 지나면 모인 요청들을 하나의 텐서로 묶습니다. 이렇게 묶인 배치를 GPU에 한 번에 전달하고, 결과를 각 요청에 분배합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 실시간 얼굴 인식 서비스를 운영한다고 가정해봅시다.
동시에 100명이 사진을 업로드하면, 배치 추론으로 처리하면 개별 처리 대비 10배 이상의 처리량을 얻을 수 있습니다. NVIDIA의 Triton Inference Server 같은 도구들은 이런 배치 처리를 자동으로 해줍니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 배치 크기를 무작정 크게 설정하는 것입니다.
배치가 클수록 대기 시간도 길어지기 때문에, 실시간 서비스에서는 적절한 균형점을 찾아야 합니다. 또한 max_wait_time을 설정하지 않으면 첫 번째 요청이 한참을 기다려야 할 수도 있습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 듣고 배치 추론을 적용한 결과, GPU 사용률이 90%까지 올라갔습니다.
"같은 하드웨어로 3배나 더 많은 요청을 처리할 수 있다니!" 김개발 씨는 감탄했습니다.
실전 팁
💡 - 배치 크기는 GPU 메모리와 지연 시간을 고려해 실험으로 결정하세요
- max_wait_time은 서비스의 SLA(서비스 수준 협약)를 고려해 설정하세요
- 동적 배칭을 지원하는 Triton, TensorFlow Serving 등의 도구를 적극 활용하세요
2. 모델 캐싱 전략
김개발 씨는 추천 시스템 API를 운영하고 있었습니다. 그런데 같은 사용자가 페이지를 새로고침할 때마다 똑같은 추론을 반복하고 있다는 사실을 발견했습니다.
이건 분명히 낭비입니다.
모델 캐싱은 이미 계산한 추론 결과를 저장해두고 재사용하는 전략입니다. 마치 도서관 사서가 자주 찾는 책을 카운터 근처에 따로 두는 것과 같습니다.
매번 서가 깊숙한 곳까지 갈 필요 없이, 손닿는 곳에서 바로 꺼내줄 수 있습니다.
다음 코드를 살펴봅시다.
import hashlib
import json
from functools import lru_cache
from typing import Any
import redis
import numpy as np
class ModelCache:
def __init__(self, model, redis_client: redis.Redis, ttl: int = 3600):
self.model = model
self.redis = redis_client
self.ttl = ttl # 캐시 유효 시간 (초)
def _generate_cache_key(self, input_data: np.ndarray) -> str:
# 입력 데이터를 해시로 변환하여 캐시 키 생성
data_bytes = input_data.tobytes()
hash_value = hashlib.sha256(data_bytes).hexdigest()
return f"model_cache:{hash_value}"
def predict(self, input_data: np.ndarray) -> Any:
cache_key = self._generate_cache_key(input_data)
# 캐시에서 먼저 조회
cached_result = self.redis.get(cache_key)
if cached_result:
return json.loads(cached_result) # 캐시 히트
# 캐시 미스 시 실제 추론 수행
result = self.model.predict(input_data)
# 결과를 캐시에 저장
self.redis.setex(cache_key, self.ttl, json.dumps(result.tolist()))
return result
김개발 씨는 쇼핑몰의 상품 추천 API를 담당하고 있습니다. 어느 날 모니터링 대시보드를 보다가 이상한 패턴을 발견했습니다.
같은 사용자에게 같은 결과를 반복해서 계산하고 있었던 것입니다. "사용자가 새로고침할 때마다 추천을 다시 계산하고 있네요.
결과는 똑같은데..." 김개발 씨가 혼잣말을 했습니다. 옆자리의 박시니어 씨가 말했습니다.
"그래서 캐싱이 필요한 거예요. 한 번 계산한 건 저장해뒀다가 다시 쓰는 거죠." 그렇다면 모델 캐싱이란 정확히 무엇일까요?
쉽게 비유하자면, 모델 캐싱은 마치 커피숍의 메뉴판과 같습니다. 손님이 "아메리카노 한 잔이요"라고 할 때마다 바리스타가 가격을 계산하지 않습니다.
이미 메뉴판에 가격이 적혀 있으니까요. 자주 주문하는 메뉴의 가격을 미리 계산해서 적어두면, 매번 계산할 필요가 없습니다.
캐싱이 없던 시절에는 어떤 문제가 있었을까요? 동일한 입력이 들어와도 매번 GPU를 돌려야 했습니다.
특히 인기 상품 페이지처럼 트래픽이 몰리는 곳에서는 같은 계산을 수천 번 반복하는 일이 발생했습니다. GPU 리소스의 심각한 낭비였고, 서버 비용도 눈덩이처럼 불어났습니다.
바로 이런 문제를 해결하기 위해 모델 캐싱 전략이 등장했습니다. 캐싱을 적용하면 동일한 요청에 대해 밀리초 단위로 응답할 수 있습니다.
GPU는 새로운 요청만 처리하면 되니 활용도도 올라갑니다. 무엇보다 서버 비용을 크게 절감할 수 있다는 장점이 있습니다.
위의 코드를 자세히 살펴보겠습니다. 먼저 _generate_cache_key 메서드에서 입력 데이터를 해시값으로 변환합니다.
이 해시값이 캐시의 키가 됩니다. predict 메서드에서는 캐시를 먼저 확인하고, 있으면 바로 반환합니다.
없을 때만 실제 추론을 수행하고 결과를 캐시에 저장합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 상품 이미지 유사도 검색 서비스를 운영한다고 가정해봅시다. 인기 상품 100개의 임베딩을 미리 캐싱해두면, 전체 요청의 80%를 캐시에서 처리할 수 있습니다.
Redis나 Memcached 같은 인메모리 캐시를 사용하면 밀리초 단위의 응답이 가능합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 TTL(Time To Live)을 고려하지 않는 것입니다. 추천 모델이 업데이트되었는데 오래된 캐시가 남아있으면 잘못된 결과를 반환할 수 있습니다.
또한 캐시 키를 잘못 설계하면 메모리가 금방 차버리거나, 캐시 히트율이 떨어질 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
캐싱을 적용한 후 API 응답 시간이 평균 200ms에서 5ms로 줄었습니다. "이제 새로고침해도 바로바로 결과가 나와요!" 사용자들의 만족도도 크게 올라갔습니다.
실전 팁
💡 - 캐시 키는 입력 데이터의 특성을 잘 반영하도록 설계하세요
- TTL은 모델 업데이트 주기와 데이터 신선도를 고려해 설정하세요
- 캐시 워밍업으로 자주 요청되는 데이터를 미리 캐싱해두면 좋습니다
3. GPU 활용 최적화
김개발 씨는 GPU 모니터링 도구를 보다가 깜짝 놀랐습니다. 분명히 비싼 A100 GPU를 쓰고 있는데, 실제 활용률은 고작 40%에 불과했습니다.
마치 스포츠카를 사놓고 시내 주행만 하는 것 같았습니다.
GPU 활용 최적화는 GPU의 연산 능력을 최대한 끌어내는 기법들의 집합입니다. 마치 교향악단 지휘자가 각 악기의 타이밍을 맞추듯, CPU와 GPU 사이의 데이터 흐름을 조율해야 합니다.
메모리 관리, 데이터 전송, 연산 스케줄링이 핵심입니다.
다음 코드를 살펴봅시다.
import torch
import torch.cuda as cuda
from torch.cuda.amp import autocast, GradScaler
class OptimizedInference:
def __init__(self, model):
self.device = torch.device("cuda" if cuda.is_available() else "cpu")
self.model = model.to(self.device)
self.model.eval()
# 모델을 추론 모드로 최적화
self.model = torch.jit.script(self.model) # TorchScript로 변환
self.model = torch.jit.freeze(self.model) # 불필요한 연산 제거
# CUDA 스트림으로 비동기 처리
self.stream = cuda.Stream()
# 메모리 풀 활용을 위한 설정
cuda.empty_cache()
def predict(self, input_data: torch.Tensor) -> torch.Tensor:
# 비동기 스트림에서 처리
with cuda.stream(self.stream):
# 데이터를 GPU로 비동기 전송
input_gpu = input_data.to(self.device, non_blocking=True)
# Mixed Precision으로 추론 (메모리 절약 + 속도 향상)
with autocast():
with torch.no_grad():
output = self.model(input_gpu)
# 스트림 동기화 (결과 반환 전 완료 대기)
self.stream.synchronize()
return output
def warmup(self, sample_input: torch.Tensor, iterations: int = 10):
# GPU 워밍업으로 첫 추론 지연 방지
for _ in range(iterations):
_ = self.predict(sample_input)
cuda.synchronize()
김개발 씨는 팀에서 가장 좋은 GPU를 배정받았습니다. NVIDIA A100, 무려 수천만 원짜리 장비입니다.
그런데 nvidia-smi 명령어로 확인해보니 GPU 활용률이 40%를 넘지 않았습니다. "이 좋은 GPU가 왜 이렇게 놀고 있는 거죠?" 김개발 씨가 한숨을 쉬었습니다.
박시니어 씨가 화면을 들여다보며 말했습니다. "GPU는 단순히 연결한다고 잘 돌아가는 게 아니에요.
제대로 활용하려면 몇 가지 최적화가 필요해요." 그렇다면 GPU 활용 최적화란 정확히 무엇일까요? 쉽게 비유하자면, GPU 활용 최적화는 마치 물류 창고 운영과 같습니다.
아무리 넓은 창고가 있어도 물건 배치가 엉망이면 효율이 떨어집니다. 입고와 출고 타이밍을 맞추고, 자주 쓰는 물건은 가까운 곳에 두고, 포장 작업과 배송 작업을 병렬로 진행해야 최대 효율을 낼 수 있습니다.
최적화 없이 GPU를 사용하면 어떤 문제가 발생할까요? 가장 큰 문제는 CPU와 GPU 사이의 병목입니다.
데이터를 GPU로 보내고 결과를 받아오는 과정에서 GPU가 대기하는 시간이 발생합니다. 또한 메모리 할당과 해제가 빈번하게 일어나면 오버헤드가 누적됩니다.
결국 비싼 GPU가 제 성능을 발휘하지 못하고 낭비됩니다. 바로 이런 문제를 해결하기 위해 여러 최적화 기법이 사용됩니다.
첫째, TorchScript를 사용하면 Python의 오버헤드를 제거할 수 있습니다. 모델을 그래프로 변환하여 최적화된 형태로 실행합니다.
둘째, CUDA 스트림을 활용하면 데이터 전송과 연산을 병렬로 처리할 수 있습니다. 셋째, Mixed Precision은 16비트 연산을 사용해 메모리와 속도를 동시에 개선합니다.
위의 코드를 자세히 살펴보겠습니다. 먼저 torch.jit.script로 모델을 TorchScript로 변환합니다.
이어서 torch.jit.freeze로 불필요한 연산을 제거합니다. cuda.Stream으로 비동기 처리를 설정하고, non_blocking=True 옵션으로 데이터 전송을 비동기로 처리합니다.
마지막으로 autocast로 Mixed Precision 추론을 적용합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 실시간 영상 분석 서비스를 운영한다고 가정해봅시다. 초당 30프레임을 처리해야 하는데, 최적화 없이는 10프레임밖에 처리하지 못합니다.
위의 기법들을 적용하면 같은 하드웨어로 40프레임 이상 처리가 가능해집니다. 서버 비용을 절반 이하로 줄일 수 있는 것입니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 워밍업을 생략하는 것입니다.
GPU는 첫 연산 시 커널 컴파일 등의 초기화가 필요하기 때문에, 첫 추론이 비정상적으로 느립니다. 서비스 시작 시 반드시 워밍업을 수행해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 최적화를 적용한 결과 GPU 활용률이 90%까지 올라갔습니다.
"같은 GPU로 처리량이 2배가 됐어요!" 김개발 씨는 뿌듯한 표정을 지었습니다.
실전 팁
💡 - 서비스 시작 시 반드시 GPU 워밍업을 수행하세요
- nvidia-smi 대신 Nsight Systems로 더 정밀한 프로파일링을 하세요
- 메모리 부족 오류가 발생하면 배치 크기를 줄이거나 gradient checkpointing을 사용하세요
4. 모델 경량화 (Quantization)
김개발 씨는 고민에 빠졌습니다. 모델 정확도는 좋은데, 추론 속도가 너무 느렸습니다.
더 좋은 GPU를 사자니 예산이 없고, 모델을 다시 학습하자니 시간이 없었습니다. 뭔가 다른 방법이 필요했습니다.
**모델 경량화(Quantization)**는 모델의 가중치를 더 작은 데이터 타입으로 변환하는 기법입니다. 마치 고해상도 사진을 적당히 압축해서 저장하는 것과 같습니다.
파일 크기는 줄어들지만 눈으로 보기에는 거의 차이가 없습니다. 32비트 실수를 8비트 정수로 바꾸면 모델 크기와 연산량이 크게 줄어듭니다.
다음 코드를 살펴봅시다.
import torch
from torch.quantization import quantize_dynamic, get_default_qconfig
from transformers import AutoModelForSequenceClassification, AutoTokenizer
def apply_dynamic_quantization(model_path: str):
# 원본 모델 로드
model = AutoModelForSequenceClassification.from_pretrained(model_path)
# 동적 양자화 적용 (Linear, LSTM 레이어 대상)
quantized_model = quantize_dynamic(
model,
{torch.nn.Linear}, # 양자화할 레이어 타입
dtype=torch.qint8 # 8비트 정수로 변환
)
return quantized_model
def apply_static_quantization(model, calibration_data):
# 정적 양자화 설정
model.qconfig = get_default_qconfig('fbgemm') # x86 CPU용
# 모델 준비
prepared_model = torch.quantization.prepare(model, inplace=False)
# 캘리브레이션 데이터로 통계 수집
with torch.no_grad():
for data in calibration_data:
prepared_model(data)
# 양자화 적용
quantized_model = torch.quantization.convert(prepared_model)
return quantized_model
def compare_model_size(original_model, quantized_model):
# 모델 크기 비교
original_size = sum(p.numel() * p.element_size() for p in original_model.parameters())
quantized_size = sum(p.numel() * p.element_size() for p in quantized_model.parameters())
print(f"원본 모델 크기: {original_size / 1e6:.2f} MB")
print(f"양자화 모델 크기: {quantized_size / 1e6:.2f} MB")
print(f"압축률: {original_size / quantized_size:.1f}x")
김개발 씨는 BERT 기반 텍스트 분류 모델을 서비스에 배포해야 했습니다. 문제는 모델 크기가 400MB나 되고, 추론에 100ms 이상 걸린다는 것이었습니다.
목표 응답 시간인 50ms를 맞추려면 뭔가 조치가 필요했습니다. "모델을 처음부터 다시 학습할 시간도 없고, 더 좋은 GPU를 살 예산도 없어요." 김개발 씨가 한숨을 쉬었습니다.
박시니어 씨가 웃으며 말했습니다. "모델을 다시 학습하지 않고도 속도를 2배로 높이는 방법이 있어요.
바로 양자화예요." 그렇다면 **양자화(Quantization)**란 정확히 무엇일까요? 쉽게 비유하자면, 양자화는 마치 음원 파일의 비트레이트를 낮추는 것과 같습니다.
원본 CD 음질은 1411kbps이지만, 대부분의 사람들은 128kbps MP3와 구분하지 못합니다. 음질을 조금 희생하면 파일 크기를 10분의 1로 줄일 수 있습니다.
딥러닝 모델도 마찬가지입니다. 32비트 실수(float32)를 8비트 정수(int8)로 바꿔도 성능 차이는 미미합니다.
양자화가 없던 시절에는 어떤 문제가 있었을까요? 대형 언어 모델을 서비스하려면 수천만 원짜리 GPU가 여러 대 필요했습니다.
모바일 기기에서는 딥러닝 모델을 구동하는 것 자체가 불가능했습니다. 모델 크기가 곧 진입 장벽이었던 시절이 있었습니다.
바로 이런 문제를 해결하기 위해 양자화 기법이 발전했습니다. 양자화의 종류는 크게 두 가지입니다.
**동적 양자화(Dynamic Quantization)**는 추론 시점에 가중치를 변환합니다. 적용이 간단하고 별도의 데이터가 필요 없습니다.
**정적 양자화(Static Quantization)**는 캘리브레이션 데이터를 사용해 최적의 변환 범위를 찾습니다. 조금 더 복잡하지만 성능이 더 좋습니다.
위의 코드를 자세히 살펴보겠습니다. quantize_dynamic 함수는 동적 양자화를 적용합니다.
torch.nn.Linear 레이어를 대상으로 8비트 정수로 변환합니다. 한 줄의 코드로 모델 크기를 4분의 1로 줄일 수 있습니다.
정적 양자화는 조금 더 복잡합니다. prepare로 모델을 준비하고, 캘리브레이션 데이터로 통계를 수집한 후, convert로 최종 변환합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 챗봇 서비스를 운영한다고 가정해봅시다.
BERT 모델에 동적 양자화를 적용하면 모델 크기가 400MB에서 100MB로 줄어듭니다. 추론 속도도 2배 빨라집니다.
정확도는 0.5% 정도만 떨어지는데, 대부분의 서비스에서는 이 정도 손실은 허용 가능합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 모든 레이어를 무조건 양자화하는 것입니다. 일부 레이어는 양자화에 민감해서 정확도가 크게 떨어질 수 있습니다.
특히 첫 번째 레이어와 마지막 레이어는 양자화에서 제외하는 것이 일반적입니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
동적 양자화를 적용한 결과 추론 시간이 100ms에서 45ms로 줄었습니다. "정확도는 거의 그대로인데 속도는 2배나 빨라졌네요!" 김개발 씨는 만족스러운 표정을 지었습니다.
실전 팁
💡 - 먼저 동적 양자화를 시도하고, 성능이 부족하면 정적 양자화를 고려하세요
- 양자화 후 반드시 정확도를 검증하세요 (특히 엣지 케이스)
- INT4 양자화는 LLM에서 효과적이지만, 작은 모델에는 INT8이 더 적합합니다
5. TensorRT 적용
김개발 씨는 NVIDIA GPU를 사용하고 있었습니다. 양자화도 적용했고, 배치 처리도 하고 있었지만, 뭔가 더 최적화할 수 있을 것 같은 느낌이 들었습니다.
그때 선배가 언급한 단어가 있었습니다. "TensorRT 써봤어요?"
TensorRT는 NVIDIA에서 만든 딥러닝 추론 최적화 엔진입니다. 마치 일반 도로 대신 고속도로를 이용하는 것과 같습니다.
같은 목적지라도 훨씬 빠르게 도착할 수 있습니다. 모델을 GPU에 최적화된 형태로 변환하여 최대 성능을 끌어냅니다.
다음 코드를 살펴봅시다.
import tensorrt as trt
import torch
import numpy as np
from torch2trt import torch2trt
class TensorRTConverter:
def __init__(self, precision: str = "fp16"):
self.precision = precision
self.logger = trt.Logger(trt.Logger.WARNING)
def convert_pytorch_to_trt(self, model, sample_input):
# PyTorch 모델을 TensorRT로 변환
model = model.eval().cuda()
sample_input = sample_input.cuda()
# torch2trt로 간편하게 변환
if self.precision == "fp16":
trt_model = torch2trt(
model,
[sample_input],
fp16_mode=True, # FP16 정밀도 사용
max_batch_size=32 # 최대 배치 크기
)
else:
trt_model = torch2trt(model, [sample_input])
return trt_model
def save_engine(self, trt_model, path: str):
# TensorRT 엔진 저장
torch.save(trt_model.state_dict(), path)
def load_engine(self, path: str, model_class):
# 저장된 엔진 로드
from torch2trt import TRTModule
trt_model = TRTModule()
trt_model.load_state_dict(torch.load(path))
return trt_model
def benchmark_comparison(original_model, trt_model, input_data, iterations=100):
import time
input_cuda = input_data.cuda()
# 원본 모델 벤치마크
torch.cuda.synchronize()
start = time.time()
for _ in range(iterations):
_ = original_model(input_cuda)
torch.cuda.synchronize()
original_time = (time.time() - start) / iterations * 1000
# TensorRT 모델 벤치마크
torch.cuda.synchronize()
start = time.time()
for _ in range(iterations):
_ = trt_model(input_cuda)
torch.cuda.synchronize()
trt_time = (time.time() - start) / iterations * 1000
print(f"원본 모델: {original_time:.2f}ms")
print(f"TensorRT: {trt_time:.2f}ms")
print(f"속도 향상: {original_time / trt_time:.1f}x")
김개발 씨는 이미지 분류 서비스의 성능을 더 끌어올리고 싶었습니다. PyTorch로 최적화를 많이 했지만, GPU의 잠재력을 100% 활용하지 못하는 느낌이었습니다.
"혹시 TensorRT 들어보셨어요?" 박시니어 씨가 물었습니다. "이름은 들어봤는데, 적용하기 어렵다고 해서..." 김개발 씨가 답했습니다.
박시니어 씨가 웃었습니다. "요즘은 정말 쉬워졌어요.
torch2trt 같은 도구를 쓰면 몇 줄이면 돼요." 그렇다면 TensorRT란 정확히 무엇일까요? 쉽게 비유하자면, TensorRT는 마치 전문 통역사와 같습니다.
PyTorch가 작성한 "명령서"를 NVIDIA GPU가 가장 잘 이해할 수 있는 형태로 번역해줍니다. 단순히 번역만 하는 게 아니라, GPU의 특성에 맞게 명령을 재구성하고 최적화합니다.
여러 개의 작은 연산을 하나로 합치기도 하고, 메모리 접근 패턴을 개선하기도 합니다. TensorRT 없이 GPU를 사용하면 어떤 문제가 있을까요?
PyTorch나 TensorFlow는 범용 프레임워크입니다. 다양한 하드웨어에서 동작해야 하기 때문에, 특정 GPU에 완전히 최적화되어 있지 않습니다.
또한 Python의 오버헤드도 있습니다. 연산 하나하나 Python 인터프리터를 거치면 속도가 느려질 수밖에 없습니다.
바로 이런 문제를 해결하기 위해 TensorRT가 만들어졌습니다. TensorRT는 여러 최적화 기법을 자동으로 적용합니다.
**레이어 퓨전(Layer Fusion)**으로 여러 연산을 하나로 합칩니다. 커널 자동 튜닝으로 GPU에 최적화된 CUDA 커널을 선택합니다.
메모리 최적화로 불필요한 메모리 복사를 제거합니다. 위의 코드를 자세히 살펴보겠습니다.
torch2trt 함수 한 줄로 PyTorch 모델을 TensorRT 엔진으로 변환할 수 있습니다. fp16_mode=True 옵션으로 FP16 정밀도를 사용하면 더 빠른 추론이 가능합니다.
변환된 모델은 저장했다가 다시 불러올 수 있어서, 매번 변환할 필요가 없습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 자율주행 차량의 물체 감지 시스템을 개발한다고 가정해봅시다. 실시간으로 초당 30프레임을 처리해야 합니다.
TensorRT를 적용하면 YOLOv5 모델의 추론 시간이 30ms에서 8ms로 줄어듭니다. 이 정도면 60프레임도 처리할 수 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 TensorRT 엔진을 다른 GPU에서 사용하려는 것입니다.
TensorRT 엔진은 특정 GPU 아키텍처에 맞게 최적화되므로, 다른 GPU에서는 동작하지 않을 수 있습니다. 배포 환경과 개발 환경의 GPU가 다르면 문제가 됩니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. TensorRT를 적용한 결과 추론 시간이 3배나 빨라졌습니다.
"이게 같은 GPU에서 나온 성능이라니!" 김개발 씨는 감탄을 금치 못했습니다.
실전 팁
💡 - 개발 환경과 배포 환경의 GPU가 같은지 반드시 확인하세요
- FP16이 지원되는 GPU(Pascal 이상)에서는 fp16_mode를 활성화하세요
- 동적 입력 크기가 필요하면 dynamic_axes 옵션을 사용하세요
6. 벤치마킹과 프로파일링
김개발 씨는 여러 최적화를 적용했습니다. 그런데 정말 효과가 있는 건지, 어디가 병목인지 정확히 알 수 없었습니다.
체감상 빨라진 것 같은데, 숫자로 증명해야 할 때가 있습니다.
벤치마킹과 프로파일링은 성능을 측정하고 병목을 찾아내는 과정입니다. 마치 건강검진을 받는 것과 같습니다.
몸 어디가 안 좋은지 정확히 알아야 제대로 된 치료를 받을 수 있습니다. 추측으로 최적화하면 시간만 낭비하게 됩니다.
다음 코드를 살펴봅시다.
import torch
import time
from contextlib import contextmanager
import cProfile
import pstats
from io import StringIO
class PerformanceProfiler:
def __init__(self):
self.records = {}
@contextmanager
def measure(self, name: str):
# GPU 동기화 후 시간 측정
torch.cuda.synchronize()
start = time.perf_counter()
yield
torch.cuda.synchronize()
elapsed = (time.perf_counter() - start) * 1000 # ms 단위
if name not in self.records:
self.records[name] = []
self.records[name].append(elapsed)
def report(self):
print("\n=== 성능 리포트 ===")
for name, times in self.records.items():
avg = sum(times) / len(times)
min_t = min(times)
max_t = max(times)
print(f"{name}: 평균 {avg:.2f}ms (최소: {min_t:.2f}ms, 최대: {max_t:.2f}ms)")
def profile_gpu_memory(model, input_data):
torch.cuda.reset_peak_memory_stats()
torch.cuda.empty_cache()
# 추론 수행
with torch.no_grad():
output = model(input_data.cuda())
# 메모리 사용량 확인
current = torch.cuda.memory_allocated() / 1e6
peak = torch.cuda.max_memory_allocated() / 1e6
print(f"현재 메모리 사용량: {current:.2f} MB")
print(f"최대 메모리 사용량: {peak:.2f} MB")
def run_latency_benchmark(model, input_data, warmup=10, iterations=100):
model.eval()
input_cuda = input_data.cuda()
# 워밍업
for _ in range(warmup):
_ = model(input_cuda)
torch.cuda.synchronize()
# 지연 시간 측정
latencies = []
for _ in range(iterations):
torch.cuda.synchronize()
start = time.perf_counter()
_ = model(input_cuda)
torch.cuda.synchronize()
latencies.append((time.perf_counter() - start) * 1000)
# 통계 출력
import numpy as np
print(f"평균 지연: {np.mean(latencies):.2f}ms")
print(f"P50: {np.percentile(latencies, 50):.2f}ms")
print(f"P95: {np.percentile(latencies, 95):.2f}ms")
print(f"P99: {np.percentile(latencies, 99):.2f}ms")
김개발 씨는 그동안 적용한 최적화의 효과를 정리해야 했습니다. 팀 회의에서 발표할 자료가 필요했기 때문입니다.
그런데 막상 정리하려니 막막했습니다. "빨라진 것 같다"는 느낌만 있을 뿐, 정확한 숫자가 없었습니다.
"느낌으로는 안 돼요. 숫자로 보여줘야 해요." 박시니어 씨가 조언했습니다.
김개발 씨가 물었습니다. "어떻게 측정해야 할까요?" 그렇다면 벤치마킹과 프로파일링이란 정확히 무엇일까요?
쉽게 비유하자면, 벤치마킹은 마치 달리기 기록 측정과 같습니다. 100미터를 몇 초에 뛰는지 정확히 재야 훈련 효과를 알 수 있습니다.
프로파일링은 더 깊이 들어갑니다. 발 구름이 느린지, 팔 동작이 비효율적인지 분석하는 것과 같습니다.
측정 없이 최적화하면 어떤 문제가 발생할까요? 가장 큰 문제는 잘못된 곳에 시간을 낭비하는 것입니다.
전체 시간의 5%밖에 차지하지 않는 부분을 열심히 최적화해봤자 효과는 미미합니다. 반면 80%를 차지하는 진짜 병목을 놓칠 수 있습니다.
이런 현상을 피하려면 반드시 측정부터 해야 합니다. 바로 이런 이유로 성능 측정 도구가 중요합니다.
측정할 때는 몇 가지 핵심 지표가 있습니다. **지연 시간(Latency)**은 하나의 요청을 처리하는 데 걸리는 시간입니다.
**처리량(Throughput)**은 초당 처리할 수 있는 요청 수입니다. P95, P99는 상위 5%, 1%의 느린 요청을 의미합니다.
평균만 보면 안 되는 이유가 여기 있습니다. 위의 코드를 자세히 살펴보겠습니다.
PerformanceProfiler 클래스는 컨텍스트 매니저 패턴을 사용합니다. with profiler.measure("이름"): 블록 안의 코드 실행 시간을 자동으로 측정합니다.
**torch.cuda.synchronize()**가 중요한데, GPU 연산은 비동기이기 때문에 동기화하지 않으면 정확한 시간을 잴 수 없습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 추론 API의 SLA가 "P99 100ms 이하"라고 가정해봅시다. 평균이 30ms여도 가끔 200ms가 나오면 SLA를 위반하는 것입니다.
벤치마킹으로 P99를 측정하고, 프로파일링으로 느린 요청의 원인을 찾아야 합니다. 대개 첫 요청이 느린 건 워밍업 문제, 특정 입력이 느린 건 캐시 미스 문제입니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 개발 환경에서만 벤치마킹하는 것입니다.
실제 프로덕션 환경은 다른 프로세스도 함께 돌아가고, 네트워크 지연도 있습니다. 프로덕션과 최대한 유사한 환경에서 테스트해야 의미 있는 결과를 얻을 수 있습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 체계적인 벤치마킹으로 최적화 효과를 정량화할 수 있었습니다.
"배치 처리로 2배, TensorRT로 3배, 총 6배의 성능 향상을 달성했습니다!" 팀 회의에서 김개발 씨의 발표는 큰 호응을 얻었습니다.
실전 팁
💡 - 반드시 워밍업 후 측정하세요 (첫 추론은 느립니다)
- 평균뿐만 아니라 P95, P99도 함께 확인하세요
- NVIDIA Nsight Systems나 PyTorch Profiler로 더 상세한 분석을 할 수 있습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Helm 마이크로서비스 패키징 완벽 가이드
Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.
보안 아키텍처 구성 완벽 가이드
프로젝트의 보안을 처음부터 설계하는 방법을 배웁니다. AWS 환경에서 VPC부터 WAF, 암호화, 접근 제어까지 실무에서 바로 적용할 수 있는 보안 아키텍처를 단계별로 구성해봅니다.
AWS Organizations 완벽 가이드
여러 AWS 계정을 체계적으로 관리하고 통합 결제와 보안 정책을 적용하는 방법을 실무 스토리로 쉽게 배워봅니다. 초보 개발자도 바로 이해할 수 있는 친절한 설명과 실전 예제를 제공합니다.
AWS KMS 암호화 완벽 가이드
AWS KMS(Key Management Service)를 활용한 클라우드 데이터 암호화 방법을 초급 개발자를 위해 쉽게 설명합니다. CMK 생성부터 S3, EBS 암호화, 봉투 암호화까지 실무에 필요한 모든 내용을 담았습니다.
AWS Secrets Manager 완벽 가이드
AWS에서 데이터베이스 비밀번호, API 키 등 민감한 정보를 안전하게 관리하는 Secrets Manager의 핵심 개념과 실무 활용법을 배워봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실전 예제와 함께 설명합니다.