본문 바로가기
Artificial Intelligence/21. PyTorch

[PYTORCH] 딥러닝 성능의 핵심 : CPU-GPU 텐서 이동 방법 2가지와 최적화 해결 가이드

by Papa Martino V 2026. 4. 5.
728x90

CPU-GPU 텐서 이동 방법
CPU-GPU 텐서 이동 방법

 

 

파이토치(PyTorch)를 이용해 딥러닝 모델을 개발할 때, 우리는 데이터가 어디에 존재하는지에 대해 끊임없이 고민해야 합니다. 수많은 행렬 연산이 필요한 딥러닝 학습과 추론에서 CPU(Central Processing Unit)GPU(Graphics Processing Unit, 특히 CUDA 기반) 간의 텐서(Tensor) 이동은 선택이 아닌 필수입니다. 하지만 단순히 "움직인다"는 것만으로는 부족합니다. 딥러닝 파이프라인에서 가장 흔하게 발생하는 성능 병목 구간이 바로 이 CPU-GPU 간의 데이터 전송(PCIe 버스를 통한)이기 때문입니다. "왜 내 모델은 GPU를 쓰는데도 느릴까?"라는 질문의 해답은 높은 확률로 비효율적인 데이터 이동에 있습니다. 이 글에서는 파이토치에서 텐서의 디바이스를 변경하는 가장 독창적이고 특별한 가치를 지닌 전문적인 방법들을 상세히 다루고, 실무에서 마주하는 성능 문제를 우아하게 해결하는 실전 가이드를 제공합니다.


1. 딥러닝에서 CPU와 GPU, 왜 데이터를 옮겨야 할까?

먼저 근본적인 차이를 이해해야 합니다. CPU는 일반적인 컴퓨팅 작업에 최적화된 복잡한 제어 로직을 가지고 있으며, 시스템 메모리(RAM)에 직접 접근합니다. 반면, GPU는 수천 개의 코어를 통해 대규모 병렬 연산(Matrix Multiplication)에 특화되어 있으며, 자체적인 고속 비디오 메모리(VRAM)를 사용합니다. 파이토치의 텐서는 기본적으로 CPU 디바이스에 생성되며, 시스템 메모리를 할당받습니다. 딥러닝 모델의 학습(Training)은 수백만 개의 가중치 업데이트 연산이 필요하므로 반드시 GPU에서 수행되어야 폭발적인 성능 향상을 얻을 수 있습니다. 이를 위해서는 CPU에 있는 데이터(예: 이미지 배치를 로드한 것)를 모델 가중치가 있는 GPU 메모리로 옮겨야 합니다. 반대로, 학습된 가중치를 저장하거나, 모델의 예측 결과를 CPU 기반의 다른 라이브러리(NumPy, Pandas, Matplotlib 등)와 연동하여 분석/시각화하기 위해서는 GPU에 있는 텐서를 다시 CPU 디바이스로 가져와야 합니다.

전문가 팁: "CPU-GPU 데이터 전송은 무료가 아니다." 딥러닝 엔지니어는 텐서 연산 시간보다 데이터 전송 시간이 더 오래 걸릴 수 있음을 항상 경계해야 합니다. 최적화의 첫걸음은 불필요한 이동을 최소화하는 것입니다.

2. 파이토치 텐서 디바이스 이동 방법 2가지 핵심 비교

파이토치는 텐서의 디바이스를 변경하는 두 가지 주요 방법을 제공합니다. 이들의 차이를 명확히 이해하는 것이 중요합니다.

비교 항목 .cuda().cpu() (레거시 방법) .to() (현대적인 통합 방법)
핵심 동작 GPU 사용 가능 시 GPU로, 또는 무조건 CPU로 캐스팅. 대상 디바이스(device), 데이터 타입(dtype)을 명시적으로 지정하여 캐스팅.
GPU 이동 tensor.cuda() tensor.to('cuda') 또는 tensor.to(device)
CPU 이동 tensor.cpu() tensor.to('cpu')
복사본 생성 여부 반드시 새로운 복사본을 생성하여 반환. (CPU->GPU, GPU->CPU 모두) 디바이스가 이미 대상과 동일하면 복사하지 않고 원본을 반환할 수도 있음. (In-place 연산 유도 가능)
장점 코드가 직관적이고 과거 레거시 코드와의 호환성. 매우 유연하며, 멀티 GPU 환경이나 CPU/GPU 하이브리드 코드 작성에 필수적. dtype 변경도 동시에 가능.
실무 권장 사항 신규 코드에서는 가급적 피할 것. 압도적으로 권장됨. 확장성과 최적화 면에서 유리.

.to() 메서드를 사용해야 하는가? (특별한 가치)

대부분의 최신 파이토치 튜토리얼과 실제 배포용 코드에서는 .to()를 사용합니다. 이는 단지 일관성 때문만이 아닙니다. .to()는 매우 강력한 장점을 가지고 있습니다.

  1. 장치 유연성: .cuda()는 내부적으로 무조건 cuda:0 (첫 번째 GPU)를 목표로 하는 경우가 많습니다. .to('cuda:1')처럼 멀티 GPU를 명시적으로 제어할 때 .to() 가 훨씬 간편합니다.
  2. 조건부 코드: GPU가 있으면 사용하고, 없으면 CPU를 쓰는 코드를 작성할 때, device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')를 정의한 후 `tensor.to(device)` 한 줄로 깔끔하게 해결됩니다.
  3. 최적화: .to()는 원본 텐서가 이미 대상 디바이스에 존재하고 dtype이 같다면, 불필요한 메모리 복사 및 할당을 피하고 원본 텐서에 대한 뷰(View)를 반환할 수 있습니다. 이는 성능에 매우 민감한 루프 내에서 큰 차이를 만듭니다. 반면 .cuda()는 항상 복사본을 만듭니다.
비밀 팁: .to()는 모델 자체에도 사용할 수 있습니다. `model.to(device)`는 모델 내부의 모든 파라미터(Weight)와 버퍼(Buffer)를 해당 디바이스로 한꺼번에 이동시킵니다.

3. 실무 해결 가이드: 개발자가 바로 적용 가능한 실전 예제 (Minimum 7선)

실제 딥러닝 프로젝트 현장에서 CPU-GPU 텐서 이동을 어떻게 활용하고 문제를 해결하는지, 개발자가 복사해서 바로 사용할 수 있는 전문적인 예제들을 준비했습니다.

Example 1: GPU가 사용 가능할 때만 이동하는 표준적인 방법 (Best Practice)

import torch

# GPU 사용 가능 여부 확인
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Current Device: {device}")

# CPU에 텐서 생성
cpu_tensor = torch.randn(3, 3)
print(f"Original Tensor Device: {cpu_tensor.device}")

# GPU가 있으면 GPU로, 없으면 CPU 유지 (안전한 현대적 방법)
optimal_tensor = cpu_tensor.to(device)
print(f"Optimal Tensor Device: {optimal_tensor.device}")

이 코드는 개발 환경(예: 로컬 노트북의 CPU)과 학습 환경(예: 서버의 GPU)이 다를 때, 코드 수정 없이 자동으로 디바이스를 최적화하는 가장 표준적인 방법입니다.

Example 2: 멀티 GPU 환경에서 특정 GPU 디바이스 명시 및 차이점 해결

import torch

# GPU가 최소 2개 이상일 때 테스트 가능
if torch.cuda.device_count() >= 2:
    # CPU 텐서
    data = torch.ones(5, 5)
    
    # 0번 GPU로 이동
    gpu0_tensor = data.to('cuda:0')
    print(f"Tensor 0: {gpu0_tensor.device}")
    
    # 1번 GPU로 이동
    gpu1_tensor = data.to('cuda:1')
    print(f"Tensor 1: {gpu1_tensor.device}")
    
    # 레거시 .cuda() 사용 시 - 내부적으로 0번 GPU로 갈 확률이 높음
    legacy_gpu_tensor = data.cuda(1) # 인자로 ID 지정 가능하지만 덜 직관적
    print(f"Legacy Tensor: {legacy_gpu_tensor.device}")
else:
    print("This example requires at least 2 GPUs.")

멀티 GPU를 사용하는 DataParallel이나 DistributedDataParallel 학습에서 모델 병렬화를 수동으로 제어할 때 필수적인 스킬입니다.

Example 3: 모델 학습 중 CPU 데이터를 GPU 배처(Batcher)로 효율적인 이동

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

# 간단한 모델과 데이터
model = nn.Linear(10, 1).to('cuda' if torch.cuda.is_available() else 'cpu')
data = torch.randn(100, 10) # CPU
targets = torch.randn(100, 1) # CPU
dataset = TensorDataset(data, targets)
dataloader = DataLoader(dataset, batch_size=32)

device = next(model.parameters()).device

# 표준적인 학습 루프 내 데이터 이동
for batch_idx, (batch_data, batch_targets) in enumerate(dataloader):
    # 중요: 배치 데이터를 모델과 동일한 GPU 디바이스로 이동
    # non_blocking=True는 pinned_memory 사용 시 비동기 이동 지원 (전문가 팁)
    batch_data = batch_data.to(device, non_blocking=True)
    batch_targets = batch_targets.to(device, non_blocking=True)
    
    # GPU에서 연산 수행
    outputs = model(batch_data)
    # loss 계산 및 backprop...
    print(f"Batch {batch_idx+1}: Data Device={batch_data.device}")

실제 학습 루프에서 가장 많이 쓰이는 패턴입니다. DataLoader는 CPU에서 데이터를 로드하고, 루프 내에서 `to(device)`를 통해 GPU로 급히 데이터를 전송합니다. 여기서 `non_blocking=True`를 사용하면 성능 최적화의 한 단계를 더 나아갈 수 있습니다.

Example 4: Pinned Memory (Page-locked RAM)를 활용한 전송 속도 폭발적 향상

import torch
import time

# 크기가 큰 텐서 생성
size = (10000, 10000)
cpu_tensor_normal = torch.randn(size) # 일반적인 CPU 텐서

# Pinned Memory에 할당된 CPU 텐서 (중요)
cpu_tensor_pinned = torch.randn(size).pin_memory()

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

if device.type == 'cuda':
    # 1. 일반 CPU 텐서 -> GPU 전송 시간 측정
    start_time = time.time()
    _ = cpu_tensor_normal.to(device)
    torch.cuda.synchronize() # GPU 연산 완료 대기
    print(f"Normal CPU to GPU: {time.time() - start_time:.4f} seconds")

    # 2. Pinned CPU 텐서 -> GPU 전송 시간 측정
    # Pinned memory는 PCIe 대역폭을 최대한 활용하여 훨씬 빠름
    start_time = time.time()
    _ = cpu_tensor_pinned.to(device)
    torch.cuda.synchronize()
    print(f"Pinned CPU to GPU: {time.time() - start_time:.4f} seconds (Faster!)")
else:
    print("This example requires a GPU.")

Pinned memory는 시스템 RAM의 일부를 페이지 폴트가 발생하지 않도록 고정하는 기술입니다. 파이토치 DataLoader의 `pin_memory=True` 옵션과 함께 사용하면 GPU로의 데이터 전송 속도를 극적으로 향상시킬 수 있는 독창적인 최적화 기법입니다.

Example 5: GPU 텐서를 CPU로 가져와 NumPy/Matplotlib와 연동 (시각화 및 결과 분석)

import torch
import numpy as np
import matplotlib.pyplot as plt

# GPU에서 모델 예측 결과가 나왔다고 가정
gpu_output_tensor = torch.randn(100).to('cuda' if torch.cuda.is_available() else 'cpu')

# 문제 발생: GPU 텐서는 직접 NumPy로 변환 불가
# try:
#     numpy_array = gpu_output_tensor.numpy() # 오류 발생!
# except TypeError as e:
#     print(f"Error: {e}")

# [해결 방법] 반드시 CPU로 먼저 이동한 후 변환해야 함
# 또한, Gradients가 계산 중이라면 .detach()도 필수 (전문가 팁)
cpu_output_array = gpu_output_tensor.detach().cpu().numpy()
print(f"Converted Array Shape: {cpu_output_array.shape}, Type: {type(cpu_output_array)}")

# Matplotlib로 시각화 (CPU 기반 라이브러리)
plt.hist(cpu_output_array)
plt.title("GPU Output Visualization after moving to CPU")
# plt.show() # 로컬 실행 시 주석 해제
plt.close() # 서버 환경 등에서는 close

모델 학습 완료 후 결과를 분석하거나, Grad-CAM처럼 모델 내부의 활성화 지도를 그릴 때 반드시 마주하는 상황입니다. `detach().cpu().numpy()`는 거의 한 세트처럼 쓰입니다.

Example 6: In-place .to() 연산을 유도하여 메모리 파편화 해결 (고급 트릭)

import torch

# 크기가 매우 큰 텐서
large_tensor = torch.zeros(1024 * 1024 * 50, dtype=torch.float32) # 약 200MB

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# [일반적인 방법] 새로운 할당 발생
# new_tensor = large_tensor.to(device)
# ... large_tensor는 여전히 CPU에 남아 메모리를 차지함 ...

# [In-place 유도 트릭] 불필요한 할당 최소화
# self.to()는 동일 디바이스라면 원본을 반환하므로, 이를 활용
# 하지만 CPU->GPU는 무조건 복사이므로 효과 없음.
# 이 트릭은 '동일 GPU 내'에서 dtype을 변경하거나 'pinned memory'로 옮길 때 효과적.

if device.type == 'cuda':
    # 텐서 자체를 'pinned' 디바이스로 In-place 변경하는 것과 유사한 효과
    large_tensor = large_tensor.pin_memory() # pinned 상태로 업데이트
    
    # 이제 GPU로 이동 (이건 복사)
    gpu_tensor = large_tensor.to(device)
    
    print(f"Large Tensor is pinned: {large_tensor.is_pinned()}")

대규모 모델에서 텐서를 반복적으로 생성/이동할 때 GPU VRAM이 부족한(Out of Memory) 문제를 겪을 수 있습니다. `.to()`를 통해 원본 변수를 업데이트하는 패턴을 사용하면 메모리 파편화를 줄이는 데 도움이 될 수 있습니다.

Example 7: GPU 텐서의 Gradients를 분리(Detach)하고 CPU로 안전하게 이동

import torch
import torch.nn as nn

# 모델과 손실 함수
model = nn.Linear(5, 1).to('cuda' if torch.cuda.is_available() else 'cpu')
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

# 학습 과정 시뮬레이션
data = torch.randn(1, 5).to(next(model.parameters()).device)
optimizer.zero_grad()
output = model(data)
loss = output.sum()
loss.backward() # Gradients 계산

# 문제 상황: 학습 중 특정 텐서 값을 기록하고 싶은데 Gradients가 붙어 있음
weight_gpu = model.weight # 이건 Gradients와 그래프에 연결된 GPU 텐서

# Gradients가 있는 GPU 텐서를 CPU로 옮기는 가장 안전하고 정확한 방법
# detach(): 계산 그래프에서 분리 (메모리 누수 방지, 학습 영향 없음)
# cpu(): CPU로 이동
# numpy(): NumPy 변환
weight_for_log = weight_gpu.detach().cpu().numpy()
print(f"Logged Weight Device: {weight_gpu.device}")
print(f"Logged Weight Numpy: {weight_for_log.shape}")

optimizer.step()

Gradients가 붙어있는 텐서를 CPU로 그냥 옮기면 메모리가 불필요하게 그래프를 추적하여 메모리 낭비가 발생할 수 있습니다. detach()는 학습 파이프라인의 안정성을 위해 필수적입니다.

4. 결론: 최적화된 CPU-GPU 데이터 관리

파이토치에서 CPU 텐서를 GPU로, GPU 텐서를 CPU로 옮기는 것은 딥러닝 성능 최적화의 심장부입니다. 단순히 .cuda().cpu() 를 사용하는 것을 넘어, 현대적인 파이토치 코드는 반드시 .to(device) 메서드를 통해 장치 유연성과 복사 오버헤드 최소화라는 두 마리 토끼를 잡아야 합니다. 특히 Pinned Memory와 `detach()`의 개념을 명확히 이해하고 실무 예제에 적용한다면, 데이터 전송이라는 성능 병목 구간을 가장 우아하게 해결하는 고난이도의 딥러닝 엔지니어로 거듭날 수 있습니다. 불필요한 데이터 이동을 줄이고, 이동해야 한다면 가장 빠르고 안전한 방법을 선택하는 것, 그것이 바로 이 글이 제공하는 특별한 가치입니다.

728x90