이번 편에서 다루는 것
이전 편에서 LLM을 활용해 강좌 설명을 보강했다. 하지만 심각한 성능 문제가 있었다.
1개 강좌 보강: 32초
1,200개 보강 예상: 10시간 40분
이번 편에서는 이 문제를 어떻게 27배 빠르게 개선했는지 다룬다.
문제 현상 분석
증상
LLM 추론이 비정상적으로 느렸다.
import time
start = time.time()
result = model.generate(**inputs, max_new_tokens=150)
elapsed = time.time() - start
print(f"추론 시간: {elapsed:.2f}초") # 32.14초
비슷한 크기의 다른 모델은 1~2초대였다. 뭔가 잘못되었다.
가설 수립
느린 원인으로 생각할 수 있는 것들:
- 모델 크기가 큼
- 토큰 생성량이 많음
- CPU 성능 부족
- 데이터 타입 문제 ← 의심
병목 분석
프로파일링
Python 프로파일러로 어디서 시간이 소요되는지 확인했다.
import cProfile
import pstats
def profile_generation():
model.generate(**inputs, max_new_tokens=150)
cProfile.run('profile_generation()', 'output.prof')
stats = pstats.Stats('output.prof')
stats.sort_stats('cumulative')
stats.print_stats(20)
발견한 것
프로파일링 결과, 이상한 함수가 많은 시간을 차지했다:
ncalls tottime cumtime filename:function
15234 18.234 18.234 {method 'to' of 'torch._C._TensorBase'}
30468 8.123 8.123 {built-in method torch._C._nn.linear}
torch._C._[TensorBase.to](<http://TensorBase.to>)() 함수가 전체 시간의 50% 이상을 차지했다.
이 함수는 텐서의 데이터 타입이나 디바이스를 변환할 때 호출된다.
원인 확인: float16 문제
모델 로드 코드 확인
# 문제의 코드
model = AutoModelForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float16, # 여기가 문제!
device_map="cpu"
)
왜 float16이 문제인가?
GPU vs CPU의 float16 지원
| 디바이스 | float16 지원 | 설명 |
| NVIDIA GPU | ✅ 네이티브 | Tensor Core로 최적화 |
| Intel CPU | ⚠️ 에뮬레이션 | float32로 변환 후 연산 |
| AMD CPU | ⚠️ 에뮬레이션 | float32로 변환 후 연산 |
| Apple Silicon | ✅ 네이티브 | Neural Engine 지원 |
운영 서버는 Intel CPU였다.
에뮬레이션이란?
CPU가 float16을 직접 계산하지 못할 때, 다음 과정이 발생한다:
1. float16 데이터를 float32로 변환
2. float32로 연산 수행
3. 결과를 float16으로 다시 변환
4. (1~3을 매 연산마다 반복)
매 연산마다 타입 변환이 발생하면서 엄청난 오버헤드가 생긴다.
해결: float32로 변경
코드 수정
# 수정 전 (느림)
model = AutoModelForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float16, # CPU에서 느림
device_map="cpu"
)
# 수정 후 (빠름)
model = AutoModelForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float32, # CPU 네이티브
device_map="cpu"
)
단 한 줄 바꿈으로 해결되었다.
성능 비교
| 설정 | 1회 추론 시간 | 비고 |
| float16 (CPU) | 32.14초 | 에뮬레이션 오버헤드 |
| float32 (CPU) | 1.18초 | 네이티브 연산 |
27배 성능 향상!
전체 보강 시간
| 설정 | 1,200개 보강 |
| float16 | 약 10시간 40분 |
| float32 | 약 24분 |
추가 최적화
1. 배치 크기 조정
LLM은 배치 처리가 어렵지만, 임베딩은 배치로 처리할 수 있다.
# XLM-R 임베딩 배치 최적화
class EmbeddingEngine:
def __init__(self, model_path: str, batch_size: int = 64):
self.model = SentenceTransformer(model_path)
self.batch_size = batch_size
def encode_batch(self, texts: List[str]) -> np.ndarray:
"""최적화된 배치 임베딩"""
return self.model.encode(
texts,
batch_size=self.batch_size,
convert_to_numpy=True,
show_progress_bar=True
)
2. 메모리 최적화
불필요한 그래디언트 계산을 비활성화:
import torch
# 추론 시 그래디언트 비활성화
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=150)
3. 토큰 생성 설정 최적화
outputs = model.generate(
**inputs,
max_new_tokens=150, # 최대 토큰 수 제한
do_sample=True, # 샘플링 활성화 (다양성)
temperature=0.7, # 적절한 무작위성
top_p=0.9, # nucleus sampling
repetition_penalty=1.1, # 반복 방지
early_stopping=True # 조기 종료
)
성능 측정 유틸리티 구현
성능을 체계적으로 측정하기 위한 유틸리티:
import time
from contextlib import contextmanager
from typing import Optional
class PerformanceMonitor:
def __init__(self):
self.measurements = []
@contextmanager
def measure(self, name: str):
""" 실행 시간 측정 컨텍스트 매니저"""
start = time.time()
yield
elapsed = time.time() - start
self.measurements.append({
'name': name,
'elapsed': elapsed
})
print(f"[{name}] {elapsed:.2f}초")
def summary(self):
"""측정 요약"""
total = sum(m['elapsed'] for m in self.measurements)
print(f"\\n=== 성능 요약 ===")
for m in self.measurements:
pct = (m['elapsed'] / total) * 100
print(f"{m['name']}: {m['elapsed']:.2f}초 ({pct:.1f}%)")
print(f"총 시간: {total:.2f}초")
사용 예시
monitor = PerformanceMonitor()
with monitor.measure("모델 로드"):
model = load_model()
with monitor.measure("설명 생성 x 100"):
for course in courses[:100]:
enhance_description(course)
monitor.summary()
출력:
[모델 로드] 3.21초
[설명 생성 x 100] 118.42초
=== 성능 요약 ===
모델 로드: 3.21초 (2.6%)
설명 생성 x 100: 118.42초 (97.4%)
총 시간: 121.63초
환경별 최적 설정
환경에 따라 최적의 설정이 다르다:
import torch
def get_optimal_dtype():
"""환경에 따른 최적 데이터 타입 반환"""
if torch.cuda.is_available():
# NVIDIA GPU: float16 최적
return torch.float16
elif torch.backends.mps.is_available():
# Apple Silicon: float16 지원
return torch.float16
else:
# CPU: float32 사용
return torch.float32
def get_device():
"""사용 가능한 디바이스 반환"""
if torch.cuda.is_available():
return "cuda"
elif torch.backends.mps.is_available():
return "mps"
else:
return "cpu"
환경 감지 적용
class DescriptionEnhancer:
def __init__(self, model_path: str):
self.device = get_device()
self.dtype = get_optimal_dtype()
print(f"디바이스: {self.device}, 데이터 타입: {self.dtype}")
self.model = AutoModelForCausalLM.from_pretrained(
model_path,
torch_dtype=self.dtype,
device_map=self.device
)
최종 성능 결과
최적화 전후 비교
| 항목 | 최적화 전 | 최적화 후 | 개선 |
| 1회 추론 | 32.14초 | 1.18초 | 27배 |
| 1,200개 보강 | 10시간 40분 | 24분 | 27배 |
| 메모리 사용 | 2.4GB | 4.8GB | 2배 증가 |
트레이드오프: 메모리는 2배 증가했지만, 속도는 27배 향상.
운영 서버 메모리(32GB)에서는 문제없는 수준이었다.
최종 파이프라인 성능
| 작업 | 시간 |
| 모델 로드 | 3초 |
| 2,000강좌 임베딩 | 40초 |
| 1,200강좌 설명 보강 | 24분 |
| 5,000학생 추천 생성 | 3분 |
| 전체 파이프라인 | 약 28분 |
성과 및 교훈
성과
| 지표 | 수치 |
| 성능 향상 | 27배 |
| 원인 | float16 → float32 변경 |
| 코드 수정 | 1줄 |
배운 점
- 당연한 것을 의심하라: float16이 항상 빠른 것은 아니다
- 환경을 고려하라: GPU/CPU에 따라 최적 설정이 다름
- 프로파일링은 필수: 병목을 찾아야 해결할 수 있음
- 단순한 해결책을 먼저: 복잡한 최적화 전에 기본부터 확인
디버깅 과정 요약
1. 문제 인지: LLM 추론이 비정상적으로 느림
2. 프로파일링: to() 함수가 50% 시간 차지
3. 가설 수립: 타입 변환 오버헤드
4. 원인 확인: CPU에서 float16 에뮬레이션
5. 해결: float32로 변경
6. 검증: 27배 성능 향상 확인
다음 편 예고
모든 핵심 기능이 완성되었다. 마지막 편에서는 API 서버 구축과 배포를 다룬다.
- FastAPI로 REST API 구축
- API 키 인증
- Nuitka로 단일 바이너리 빌드
- 운영 서버 배포
시리즈 목차
- 프로젝트 소개 - 왜 AI 추천이 필요했나
- 추천을 위한 데이터 파이프라인 설계
- XLM-R로 강좌 임베딩 구축하기
- 추천 알고리즘 설계 - 가중치 기반 개인화
- LLM으로 강좌 설명 보강하기
- 성능 최적화 - CPU에서 27배 빠르게 ← 현재 글
- API 서버 구축과 배포
'프로그래밍 > AI 교과 추천 시스템 개발기' 카테고리의 다른 글
| 5편. LLM으로 강좌 설명 보강하기 (0) | 2026.02.19 |
|---|---|
| 4편. 추천 알고리즘 설계 - 가중치 기반 개인화 (0) | 2026.02.18 |
| 3편. XLM-R로 강좌 임베딩 구축하기 (0) | 2026.02.17 |
| 2편. 추천을 위한 데이터 파이프라인 설계 (0) | 2026.02.16 |
| 1편. 프로젝트 소개 - 왜 AI 추천이 필요했나 (0) | 2026.02.15 |