본문 바로가기
Project/🚀 AI Finance 2026

#27 [모듈4-3] CUDA, MPS, CPU - PyTorch 디바이스 감지와 혼합 정밀도 학습

by 로띠 2026. 3. 19.
딥러닝

Mixed Precision & Device

CUDA, MPS, CPU — PyTorch 디바이스 감지와 혼합 정밀도 학습

Data Collect Feature Eng. Deep Learning Signal

이 글은 AI_Finance(ALCHEMETRIC) 프로젝트에서 PyTorch 딥러닝 모델을 다양한 하드웨어에서 실행할 때 마주치는 문제를 다룬다. NVIDIA GPU가 없는 Apple Silicon Mac에서는 어떻게 학습을 돌리는지, Mixed Precision Training은 왜 CUDA에서만 쓰는지, 그리고 디바이스 감지를 자동화한 실제 코드를 살펴본다.

1. 왜 디바이스가 중요한가?

딥러닝 모델은 수백만 개의 숫자를 곱하고 더하는 작업을 수십만 번 반복한다. 이 연산을 어디서(어떤 하드웨어에서) 실행하느냐에 따라 학습 시간이 10분이 될 수도, 10시간이 될 수도 있다.

비유하면, 같은 요리를 만들더라도 가스레인지(CPU)로 하나씩 굽는 것과 대형 오븐(GPU)으로 한꺼번에 굽는 것의 차이다. 딥러닝에서 "디바이스"란, 이 연산을 실행하는 프로세서를 의미한다.

문제: 개발자마다 하드웨어가 다르다

ALCHEMETRIC 프로젝트는 다양한 환경에서 실행될 수 있다.

환경 하드웨어 디바이스
클라우드 서버 NVIDIA A100 / V100 cuda
개발용 MacBook Apple M1 / M2 / M3 mps
일반 PC / CI 서버 Intel / AMD CPU cpu

코드에 torch.device("cuda")를 하드코딩하면, NVIDIA GPU가 없는 Mac에서는 바로 에러가 난다. 반대로 torch.device("cpu")로 고정하면, 비싼 GPU를 놀게 만드는 셈이다.

핵심 질문: 어떤 환경에서든 "가장 빠른 장치"를 자동으로 골라서 쓸 수는 없을까?

2. GPU vs MPS vs CPU — 세 가지 연산 장치

CUDA (NVIDIA GPU)

CUDA는 NVIDIA가 만든 GPU 병렬 컴퓨팅 플랫폼이다. 딥러닝의 사실상 표준이며, PyTorch의 모든 기능이 CUDA를 기준으로 먼저 개발된다. 수천 개의 코어가 행렬 연산을 동시에 처리하므로 CPU 대비 10~100배 빠르다.

CUDA에서만 사용할 수 있는 주요 기능은 다음과 같다.

기능 설명 CUDA MPS CPU
AMP (Mixed Precision) float16 혼합 학습 O X X
Tensor Core 하드웨어 가속 행렬곱 O X X
cuDNN 최적화된 CNN/RNN 연산 O X X
Multi-GPU 여러 GPU 병렬 학습 O X X

MPS (Apple Silicon)

MPS(Metal Performance Shaders)는 Apple이 제공하는 GPU 연산 프레임워크다. M1, M2, M3 칩에 내장된 GPU를 PyTorch에서 활용할 수 있게 해 준다. PyTorch 1.12부터 공식 지원이 시작되었다.

CPU보다 2~5배 빠르지만, CUDA만큼 성숙하지는 않다. 특히 float16 autocast가 아직 완전히 지원되지 않아 Mixed Precision을 쓸 수 없다.

CPU (범용 프로세서)

어디서든 작동하는 최후의 보루다. 속도는 가장 느리지만, 별도의 드라이버나 하드웨어 없이 동작한다. CI/CD 파이프라인이나 단순 추론(inference)에서는 CPU로도 충분한 경우가 많다.

비유: CUDA는 대형 화물차(빠르고 많이 실을 수 있지만 NVIDIA 전용 도로 필요), MPS는 전기 자전거(배터리 내장, CPU보다 빠르지만 화물차만큼은 아님), CPU는 걸어가기(어디서든 가능하지만 느림).

3. 디바이스 자동 감지 — _get_device()

ALCHEMETRIC의 dl_trainer.py에는 실행 환경에 맞는 디바이스를 자동으로 선택하는 함수가 있다.

def _get_device() -> torch.device:
    """사용 가능한 디바이스 자동 감지."""
    # 1순위: NVIDIA CUDA GPU
    if torch.cuda.is_available():
        return torch.device("cuda")
    # 2순위: Apple Silicon MPS
    if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
        return torch.device("mps")
    # 3순위: CPU (항상 사용 가능)
    return torch.device("cpu")

우선순위: CUDA → MPS → CPU

이 함수의 로직은 단순하지만 의도가 명확하다.

1단계: torch.cuda.is_available() — NVIDIA GPU가 있으면 무조건 CUDA를 쓴다. 가장 빠르고 모든 PyTorch 기능을 지원하기 때문이다.

2단계: torch.backends.mps.is_available() — Apple Silicon Mac이면 MPS를 쓴다. CPU보다 빠르기 때문이다. hasattr 체크는 구버전 PyTorch(1.12 미만)에서 torch.backends.mps 자체가 없을 수 있기 때문에 필요하다.

3단계: 둘 다 없으면 CPU를 사용한다. 어떤 환경에서든 코드가 동작하도록 보장하는 폴백(fallback)이다.

사용 위치 — 학습기 초기화

DeepLearningTrainer 클래스가 생성될 때 디바이스가 결정되고, 이후 모든 학습과 추론에서 이 디바이스를 사용한다.

class DeepLearningTrainer:
    def __init__(self):
        SAVED_MODELS_DIR.mkdir(parents=True, exist_ok=True)
        self.device = _get_device()
        logger.info(
            f"DL Trainer 초기화 (device={self.device})",
            "__init__",
        )

이렇게 하면 개발자가 직접 디바이스를 지정할 필요 없이, 코드 한 줄 변경 없이 어떤 환경에서든 최적의 장치를 사용하게 된다.

핵심: 디바이스 감지를 한 곳에서 처리하면, 나중에 새로운 가속기(예: Intel XPU)가 추가되더라도 이 함수만 수정하면 전체 코드가 대응된다.

4. Mixed Precision Training — float32와 float16의 혼합

float32 vs float16 — 숫자의 정밀도

컴퓨터에서 소수점이 있는 숫자(실수)를 저장하는 방식에는 여러 종류가 있다.

타입 비트 수 정밀도 메모리 속도
float32 32비트 높음 (소수점 7자리) 4 bytes 기본
float16 16비트 낮음 (소수점 3자리) 2 bytes 2배 빠름

float16은 메모리를 절반만 사용하고 연산도 빠르다. 하지만 정밀도가 낮아서 학습이 불안정해질 수 있다. 특히 gradient(기울기) 값이 매우 작아지면 float16에서는 0으로 반올림되어 학습이 멈추는 underflow 문제가 발생한다.

Mixed Precision — "적재적소에 맞는 정밀도"

Mixed Precision Training (혼합 정밀도 학습)은 두 정밀도를 동시에 사용하는 기법이다.

Forward pass (순전파): float16으로 빠르게 계산한다. 행렬곱 같은 대량 연산은 정밀도를 조금 낮춰도 결과에 큰 차이가 없다.

Backward pass (역전파): float32로 정밀하게 기울기를 계산한다. 기울기 값은 매우 작을 수 있어서 높은 정밀도가 필요하다.

Weight update (가중치 갱신): float32로 유지한다. 모델의 핵심 파라미터이므로 정밀도를 양보하면 안 된다.

비유: 요리할 때, 재료를 거칠게 다지는 작업(forward)은 큰 칼로 빠르게 하고, 양념 계량(backward)은 정밀 저울로 정확하게 하는 것과 같다. 결과물의 맛(정확도)은 유지하면서 조리 시간(학습 시간)을 줄인다.

AMP란?

AMP(Automatic Mixed Precision)는 PyTorch가 제공하는 Mixed Precision 도구다. 개발자가 직접 float16/float32 변환을 관리하지 않아도, PyTorch가 자동으로 각 연산에 적합한 정밀도를 선택해 준다.

AMP의 두 핵심 구성 요소는 다음과 같다.

구성 요소 역할
autocast 연산별로 float16/float32를 자동 선택하는 컨텍스트 매니저
GradScaler float16에서 발생하는 gradient underflow를 방지하기 위해 loss를 스케일링

5. AMP 학습 루프 — autocast + GradScaler

이제 ALCHEMETRIC 프로젝트의 실제 학습 루프 코드를 살펴보자. 핵심은 디바이스에 따라 AMP를 켜고 끄는 분기다.

AMP 사용 여부 결정

# CUDA에서만 AMP 활성화
use_amp = self.device.type == "cuda"
amp_scaler = torch.amp.GradScaler() if use_amp else None

use_amp는 불리언 플래그로, 디바이스가 CUDA일 때만 True가 된다. MPS나 CPU에서는 False이므로 AMP를 사용하지 않는다.

텐서를 디바이스로 이동

for X_batch, y_batch in train_loader:
    X_batch = X_batch.to(self.device)   # 텐서를 GPU/MPS/CPU로 이동
    y_batch = y_batch.to(self.device)   # 라벨도 같은 디바이스로
    optimizer.zero_grad()

.to(device)는 텐서를 특정 디바이스의 메모리로 복사한다. 모델과 데이터가 같은 디바이스에 있어야 연산이 가능하다. 다른 디바이스에 있으면 PyTorch가 런타임 에러를 발생시킨다.

CUDA 경로 — AMP 활성화

if use_amp:
    # Step 1: autocast 블록 안에서 forward pass (float16)
    with torch.amp.autocast(device_type="cuda"):
        logits = model(X_batch)
        loss = criterion(logits, y_batch)

    # Step 2: loss를 스케일링한 뒤 backward (gradient underflow 방지)
    amp_scaler.scale(loss).backward()

    # Step 3: 스케일링을 되돌린 뒤 gradient clipping
    amp_scaler.unscale_(optimizer)
    nn.utils.clip_grad_norm_(model.parameters(), grad_clip)

    # Step 4: 옵티마이저 step + scaler 업데이트
    amp_scaler.step(optimizer)
    amp_scaler.update()

각 단계를 하나씩 살펴보자.

Step 1 — autocast: torch.amp.autocast 블록 안에서는 행렬곱 등 대량 연산이 자동으로 float16으로 실행된다. BatchNorm 같이 정밀도가 중요한 연산은 float32를 유지한다.

Step 2 — scale + backward: GradScaler가 loss 값을 크게 늘린 뒤 역전파한다. 이렇게 하면 아주 작은 gradient도 float16에서 0이 되지 않는다.

Step 3 — unscale + clip: 옵티마이저에 전달하기 전에, 스케일링을 원래대로 되돌린다. 그 뒤 gradient clipping으로 기울기 폭발을 방지한다.

Step 4 — step + update: 스케일된 gradient로 가중치를 갱신하고, scaler의 스케일 팩터를 다음 iteration에 맞게 업데이트한다.

MPS / CPU 경로 — 일반 학습

else:
    # float32 기본 정밀도로 학습
    logits = model(X_batch)
    loss = criterion(logits, y_batch)
    loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
    optimizer.step()

MPS와 CPU에서는 autocast와 GradScaler 없이 float32만으로 학습한다. 코드가 훨씬 단순하고, 정밀도 문제가 발생하지 않는다.

추론(Inference) 시 디바이스 관리

@torch.no_grad()
def _evaluate(self, model: nn.Module, loader: DataLoader) -> dict:
    model.eval()
    for X_batch, y_batch in loader:
        X_batch = X_batch.to(self.device)   # 학습과 같은 디바이스
        logits = model(X_batch)
        probs = torch.softmax(logits, dim=1)
        preds = logits.argmax(dim=1)
        # 결과를 CPU로 가져와서 numpy 변환
        all_preds.extend(preds.cpu().numpy())

추론 시에는 @torch.no_grad() 데코레이터로 gradient 계산을 비활성화한다. 학습할 필요가 없으므로 메모리와 연산을 절약할 수 있다. 결과 텐서는 .cpu().numpy()로 CPU에 가져와서 scikit-learn 메트릭 계산에 사용한다.

주의: GPU/MPS 텐서를 직접 numpy로 변환하면 에러가 발생한다. 반드시 .cpu()를 먼저 호출해야 한다.

6. MPS의 현실 — Apple Silicon에서의 제약

Apple Silicon의 MPS 백엔드는 Mac에서 딥러닝을 돌릴 수 있게 해준다는 점에서 획기적이다. 하지만 CUDA와 동일한 수준을 기대하면 안 된다.

MPS가 좋은 점

1) 별도 GPU 없이 가속: MacBook 한 대로 개발과 학습을 모두 할 수 있다. NVIDIA GPU가 없어도 CPU보다 빠르게 프로토타이핑이 가능하다.

2) 통합 메모리 아키텍처: Apple Silicon은 CPU와 GPU가 같은 메모리를 공유한다. .to(device) 시 데이터 복사 오버헤드가 CUDA보다 적다.

3) 전력 효율: GPU 서버 대비 전력 소모가 현저히 낮다. 금융 데이터처럼 비교적 작은 모델에서는 충분한 성능을 낸다.

MPS의 한계

한계 설명 프로젝트 영향
float16 autocast 미지원 MPS에서 autocast 사용 시 일부 연산에서 에러 발생 AMP를 CUDA에서만 활성화
일부 연산 미구현 특정 PyTorch 연산이 MPS에서 지원되지 않음 CPU 폴백이 필요한 경우 존재
수치 정밀도 차이 CUDA 대비 미세한 결과 차이 발생 가능 금융 모델에서 재현성 검증 필요
디버깅 도구 부족 CUDA의 nvprof, nsight 같은 프로파일링 도구 없음 성능 병목 분석이 어려움

왜 AMP를 MPS에서 쓰지 않는가?

가장 핵심적인 이유는 MPS 백엔드의 float16 연산이 아직 불완전하기 때문이다. torch.amp.autocast(device_type="mps")를 사용하면, 일부 레이어에서 지원하지 않는 연산을 만나 런타임 에러가 발생할 수 있다.

또한 MPS GPU에는 NVIDIA의 Tensor Core가 없다. Tensor Core는 float16 행렬곱을 하드웨어 수준에서 가속하는 전용 유닛인데, 이것이 없으면 float16을 써도 속도 이점이 크지 않다.

# 이 코드가 AMP를 CUDA에서만 쓰는 이유
use_amp = self.device.type == "cuda"

# MPS에서 autocast를 강제로 사용하면?
# with torch.amp.autocast(device_type="mps"):  # 일부 연산에서 에러!
#     logits = model(X_batch)  # RuntimeError: MPS does not support ...

따라서 우리 프로젝트에서는 안정성을 우선으로, MPS에서는 float32 기본 정밀도로 학습한다. Apple이 MPS의 float16 지원을 개선하면 향후 활성화할 수 있도록, 디바이스 체크 한 줄로 분기를 관리하고 있다.

실무 팁: Apple Silicon Mac에서 개발할 때는 MPS로 빠르게 프로토타이핑하고, 본격적인 학습은 CUDA가 있는 클라우드 서버(AWS, GCP)에서 AMP를 켜고 돌리는 것이 현실적인 워크플로우다.

7. 프로젝트 적용 정리

ALCHEMETRIC 프로젝트에서 디바이스와 Mixed Precision이 어떻게 통합되어 있는지 한눈에 정리하면 다음과 같다.

전체 흐름

# 1. 디바이스 감지 (앱 시작 시 1회)
device = _get_device()          # cuda / mps / cpu

# 2. 모델을 디바이스로 이동
model = model.to(device)

# 3. AMP 결정 (CUDA에서만)
use_amp = device.type == "cuda"
amp_scaler = torch.amp.GradScaler() if use_amp else None

# 4. 학습 루프
for X_batch, y_batch in train_loader:
    X_batch = X_batch.to(device)     # 데이터 이동
    y_batch = y_batch.to(device)

    if use_amp:
        # CUDA: autocast + GradScaler
        ...
    else:
        # MPS/CPU: float32 기본 학습
        ...

# 5. 추론
with torch.no_grad():
    preds = model(X_batch.to(device))
    result = preds.cpu().numpy()     # CPU로 복귀

디바이스별 동작 요약

항목 CUDA MPS CPU
디바이스 감지 torch.cuda.is_available() torch.backends.mps.is_available() 기본 폴백
AMP 활성화 비활성화 비활성화
정밀도 float16 + float32 혼합 float32 float32
GradScaler 사용 None None
상대 속도 10~100x 2~5x 1x (기준)
추론 시 torch.no_grad() torch.no_grad() torch.no_grad()

설계 원칙

1) 단일 진입점: _get_device() 함수 하나로 디바이스 감지를 중앙 관리한다. 새로운 가속기가 추가되면 이 함수만 수정하면 된다.

2) 안전한 분기: AMP를 CUDA에서만 활성화하여, MPS/CPU에서의 런타임 에러를 원천 차단한다.

3) 일관된 텐서 관리: 모든 텐서와 모델이 self.device를 통해 같은 디바이스에 배치된다. 디바이스 불일치 에러를 방지한다.

4) 추론 시 CPU 복귀: .cpu().numpy() 패턴으로, GPU/MPS 결과를 scikit-learn 등 CPU 기반 라이브러리에 안전하게 전달한다.

한 줄 요약: 디바이스 감지는 _get_device() 한 곳에서, AMP는 use_amp 플래그 하나로 — 어떤 하드웨어에서든 같은 코드가 최적의 성능으로 돌아간다.