PyTorch Tensor는 왜 Storage와 Metadata로 분리되어 있는가
단순한 다차원 배열처럼 보이는 Tensor가 실제로 6원소 튜플로 구성된 이유부터, stride가 CUDA 커널 선택을 바꾸고 view가 zero-copy인 이유까지 추적한다.
- 01 PyTorch Tensor는 왜 Storage와 Metadata로 분리되어 있는가
- 02 PyTorch autograd는 어떻게 gradient를 계산하는가
- 03 PyTorch Dispatcher는 어떻게 동작하는가
- 04 GPU 커널 성능은 무엇이 결정하는가
- 05 PyTorch Custom Kernel의 핵심은 HBM을 피하는 것이다
- 06 Mixed Precision Training의 수학 — FP16은 왜 위험하고 BF16은 왜 안전한가
- 07 torch.compile은 Python 코드를 어떻게 GPU 커널로 바꾸는가
PyTorch를 쓰는 대부분의 사람은 Tensor를 “다차원 배열”로 이해한다. 그러나 .view()가 왜 어떤 경우에 실패하는지, .contiguous()가 왜 갑자기 필요해지는지, permute() 후 convolution이 왜 느려지는지 — 이 질문들은 모두 같은 뿌리에서 나온다. PyTorch Tensor는 배열이 아니라 Storage와 Metadata의 분리된 묶음이다. 이 분리가 어떻게 설계되었고, 어떤 결과를 만드는가?
Tensor는 6원소 튜플이다
PyTorch의 Tensor를 정확하게 쓰면 다음과 같다.
Storage는 단순한 1D byte array다. FP32 12개짜리 tensor라면 48바이트의 raw memory가 전부다. 나머지 다섯 원소는 이 Storage를 어떻게 해석할지를 결정하는 metadata다.
index 에서 Storage 안의 위치를 계산하는 공식은 단순하다.
shape (3, 4)의 tensor를 예로 들면, stride는 (4, 1)이고 tensor[1, 2]의 offset은 이다. Storage의 6번째 float을 읽으면 된다.
이 구조가 중요한 이유는 Storage를 건드리지 않고 metadata만 바꾸면 전혀 다른 tensor가 된다는 점이다.
Stride가 만드는 zero-copy 연산
.view(), .transpose(), .permute() 등으로 생성된 tensor는 원본과 같은 Storage를 가리킨다.
.view(new_shape)는 새로운 stride를 계산할 뿐 Storage를 변경하지 않는다. .transpose(dim0, dim1)은 stride[dim0]과 stride[dim1]을 교환하고 size도 교환한다. 두 경우 모두 storage.data_ptr()이 동일하므로 같은 메모리를 가리킨다.
결과적으로 y = x.view(2, 6)를 실행한 후 y[0, 0] = 999로 수정하면 x[0, 0]도 999가 된다. Storage를 공유하기 때문이다.
Row-major(C-order) tensor에서 stride 공식은 명확하다.
shape (2, 3, 4)라면 stride는 (12, 4, 1)이다. 이 조건을 만족하면 C-contiguous, 만족하지 않으면 non-contiguous다.
x = torch.arange(12.0).reshape(3, 4)
y = x.view(2, 6)
print(x.data_ptr() == y.data_ptr()) # True — 같은 Storage
print(x.stride(), y.stride()) # (4, 1) vs (6, 1)
NCHW vs NHWC — stride가 성능을 바꾼다
같은 데이터를 NCHW (N, C, H, W)로 배치하면 stride는 (C·H·W, H·W, W, 1)이다. NHWC (N, H, W, C)로 permute하면 stride는 (H·W·C, W·C, C, 1)이 된다. shape이 달라진 게 아니다 — 메모리 접근 패턴이 달라진 것이다.
cuDNN은 입력 tensor의 stride pattern을 확인해 최적 커널을 선택한다. NCHW는 채널이 연속으로 저장되어 채널별 연산에 캐시 효율이 좋고, NHWC는 같은 위치의 모든 채널이 연속되어 pixel-wise 처리에 유리하다. 이 차이가 convolution 성능을 2-10배 바꿀 수 있다.
x_nchw = torch.randn(2, 3, 4, 4)
print(x_nchw.stride()) # (48, 16, 4, 1)
print(x_nchw.is_contiguous()) # True
x_nhwc = x_nchw.permute(0, 2, 3, 1)
print(x_nhwc.stride()) # (48, 12, 3, 1)
print(x_nhwc.is_contiguous()) # False
permute() 후에는 non-contiguous 상태다. 이 tensor를 reshape()하거나 matmul()에 넘기면 내부적으로 .contiguous()가 호출되어 암묵적 copy가 발생한다. 숨겨진 비용이다.
reshape은 view인가 copy인가
.reshape()은 다음과 같이 동작한다.
contiguous tensor라면 zero-copy view다. non-contiguous라면 먼저 copy를 만든 후 view를 반환한다. data_ptr()의 변화로 확인할 수 있다.
x = torch.arange(24.0).reshape(2, 3, 4)
y = x.reshape(6, 4)
print(x.data_ptr() == y.data_ptr()) # True — view
x_t = x.permute(2, 1, 0)
z = x_t.reshape(4, 6)
print(x_t.data_ptr() == z.data_ptr()) # False — copy
.expand()와 .repeat()의 차이도 같은 맥락이다. expand()는 stride에 0을 삽입해 메모리 재사용(zero-copy)하고, repeat()은 실제로 데이터를 복제한다. a.expand(32, 1000)의 Storage는 원본과 동일하지만 a.repeat(32, 1)의 Storage는 32배 크다.
트레이드오프
Storage-Metadata 분리는 강력한 zero-copy 연산을 가능하게 하지만, non-contiguous 상태를 언제든지 만들어낼 수 있다. permute() 후 연산을 이어붙이는 코드는 눈에 보이지 않는 implicit copy를 곳곳에 숨길 수 있다. 대규모 tensor에서 이 copy는 OOM 또는 PCIe 병목으로 이어진다.
Device 차원에서도 같은 원칙이 적용된다. tensor.to('cuda')는 Storage를 GPU HBM으로 복사한다. PCIe 4.0 x16 기준 bandwidth는 약 16 GB/s이므로, 400MB tensor 하나를 옮기는 데 ~25ms가 걸린다. pinned memory(pin_memory=True)를 쓰면 OS의 페이지 교환 없이 직접 DMA로 전송되어 2-3배 빠르다.
dtype도 Storage와 독립된 metadata다. int32 + float32의 결과는 float32이고, tensor가 존재하면 tensor의 dtype이 scalar보다 우위다. torch.tensor(0.5) + 1의 결과가 float64가 아니라 float32인 이유다.
정리
- PyTorch Tensor는
(Storage, size, stride, offset, dtype, device)6원소 튜플이다. “다차원 배열”이 아니다. - index에서 메모리 위치는 로 계산된다. stride가 이 변환의 핵심이다.
.view(),.transpose(),.permute()는 Storage를 공유한다(zero-copy)..contiguous()와.repeat()은 새 Storage를 만든다(copy)..reshape()는 contiguous면 view, non-contiguous면 copy다. 자동이지만 비용은 숨겨진다.- NCHW vs NHWC의 stride 차이가 cuDNN 커널 선택을 바꾸고, 성능을 수 배 달라지게 한다.
이 구조를 이해하면 .contiguous()가 왜 필요한지, OOM이 어디서 왔는지, .permute() 이후 연산이 왜 느려지는지 — 모두 같은 프레임으로 읽힌다.