PyTorch Tensor 인덱싱·stride·view·contiguous·GPU coalescing 총정리

김민준·2026년 1월 1일

0. 왜 이 주제가 헷갈리는가

처음엔 이렇게 시작한다.

  • 파이썬 리스트는 a[0][1]은 되는데 a[0,1]은 안 됨
  • PyTorch Tensor는 a[0,1]이 됨
  • 그리고 “GPU는 병렬 계산에 강하다” → “그럼 연속 메모리랑 무슨 상관?”
  • stride, contiguous, view, transpose가 나오면 갑자기 메모리 이야기가 끼어듦

이 글의 목표는 한 줄이다:

Tensor의 인덱싱/변형은 “데이터를 움직이는 것”이 아니라, 대부분 “메모리를 해석하는 규칙(shape/stride)을 바꾸는 것”이며,
이 규칙성은 GPU가 빠르게 처리하기 좋은 패턴과 강하게 연결된다.


1. 리스트 vs Tensor: “다차원 배열”이라는 말의 진짜 의미

1-1) 파이썬 리스트는 “중첩된 컨테이너”

a = [[1,2,3],
     [4,5,6],
     [7,8,9]]

이건 본질적으로:

  • 바깥 리스트가 “행 리스트들을 담는 컨테이너”
  • 각 행도 또 하나의 리스트

그래서 인덱싱이 이렇게 “두 번” 일어난다:

  • a[0] → 첫 번째 “행 리스트”
  • a[0][1] → 그 행 리스트의 2번째 원소

a[0,1]은 안 되나?

리스트의 인덱싱(__getitem__)은 “인덱스 1개”를 기대한다.
a[0,1]은 인덱스가 두 개인 게 아니라, 튜플 하나 (0,1)을 인덱스로 넣은 것으로 해석된다. 리스트는 그 튜플을 “(행,열)”로 해석하는 로직이 없다.


1-2) Tensor는 “진짜 n차원 배열(단일 객체)”

a = torch.tensor([[1,2,3],
                  [4,5,6],
                  [7,8,9]])

Tensor는 내부적으로 shape=(3,3) 같은 “다차원 구조 정보”를 갖는 단일 객체다.

그래서 a[0,1]은 “튜플로 (행,열)”을 주는 게 자연스럽다.

정확한 표현: “Tensor는 다차원 배열이라 튜플 인덱싱이 가능하다.”
부정확한 표현: “튜플 인덱싱이 더 빨라서 좋다.” (대부분의 경우 ‘속도’가 핵심이 아니라 ‘구조’가 핵심, 다차원 배열이니 한번에 행열 정보 전달 하는 것이 자연스럽다)


2. Tensor 메모리 모델: storage · dtype · shape · stride

Tensor를 이해하는 핵심은 이 4가지:

  1. storage: 실제 데이터가 들어있는 메모리 블록
  2. dtype: 원소 1개의 크기(예: float32=4바이트)
  3. shape: 논리적 차원 크기
  4. stride: 각 차원을 1 증가시킬 때, storage에서 몇 칸(원소 단위) 이동하는지

2-1) 주소(address)는 “바이트 단위”가 일반적

메인 메모리는 보통 바이트 주소(byte-addressable)다.

  • 주소 +1 → 1바이트 이동
  • float32(4바이트) 원소가 배열에 연속으로 있다면
    a[0]의 첫 바이트 주소가 1000이면
    a[1]의 첫 바이트 주소는 1004가 된다.

“4바이트 데이터의 주소”는 보통 그 4바이트 덩어리의 첫 바이트 주소를 말한다.


2-2) stride는 “주소 자체”가 아니라 “주소 계산 규칙”

Tensor에서 “원소 (i,j)”가 실제 메모리에서 어디에 있는지 계산은:

  • 원소 단위 stride로 보면:
    [
    \text{offset} = i \cdot stride[0] + j \cdot stride[1]
    ]
  • 바이트 주소로 보면 (dtype 바이트 크기를 곱함):
    [
    \text{byte_addr} = base + (\text{offset} \cdot \text{bytes_per_element})
    ]

3. contiguous(연속) vs non-contiguous(비연속): “데이터가 흩어진 게 아니다”

여기서 초심자가 가장 많이 오해하는 포인트가 있다.

3-1) 비연속 텐서 = 데이터가 메모리에 ‘흩어져 저장’된 것?

❌ 보통 그런 뜻이 아니다.

✅ 더 정확히는:

non-contiguous 텐서 = storage는 연속 메모리지만,
텐서가 그 storage를 “연속적으로 읽히는 형태”로 해석하지 않는 상태

즉, stride 규칙이 “연속 접근에 불리한(점프하는)” 형태다.


3-2) transpose(.t())가 stride를 바꾸는 이유 (시각화)

원본 a (3×3)

a =
[[1,2,3],
 [4,5,6],
 [7,8,9]]

storage(메모리)에 row-major로 들어있다고 하면:

storage index:  0 1 2 3 4 5 6 7 8
storage value: [1 2 3 4 5 6 7 8 9]

원본 a.stride() = (3,1) 의미:

  • 행(i) +1 → storage에서 +3
  • 열(j) +1 → storage에서 +1

즉:

a[i, j]는  index = i*3 + j*1

전치 b = a.t()

논리적으로는:

b =
[[1,4,7],
 [2,5,8],
 [3,6,9]]

하지만 복사 없이 “해석 규칙만 바꾸면” 된다.

전치의 핵심:

  • b의 행 이동은 원래 a의 열 이동(=+1)
  • b의 열 이동은 원래 a의 행 이동(=+3)

그래서 b.stride() = (1,3).

실제로 b에서 이동해보기
  • b[0,0] = 1 (storage index 0)
  • b[0,1] = 4 (storage index 3) ← 오른쪽으로 갔는데 +3 점프
  • b[0,2] = 7 (storage index 6)

즉 b는 “행렬 모양은 3×3”이어도, 메모리에서 “행을 따라 연속”이 아니다 → is_contiguous()가 False가 된다.


3-3) step slicing a[:, ::2]가 “열 이동 시 2칸 점프”인 이유 (시각화)

a = torch.arange(12).reshape(3,4)
a =
[[ 0,  1,  2,  3],
 [ 4,  5,  6,  7],
 [ 8,  9, 10, 11]]

storage는:

[0,1,2,3,4,5,6,7,8,9,10,11]

원본 a.stride() = (4,1):

  • 행 +1 → +4
  • 열 +1 → +1

이제:

b = a[:, ::2]   # 0,2 열만 뽑음

논리적으로 b는:

b =
[[ 0,  2],
 [ 4,  6],
 [ 8, 10]]

b에서 열을 오른쪽으로 한 칸 이동(b[0,0] → b[0,1])은 원본에서:

  • a[0,0]a[0,2]로 이동
  • storage index 0 → 2 (두 칸 점프)

그래서 b.stride()는 (4,2)가 된다.

비유 보정: “데이터가 여기저기 흩어진 게 아니라,
같은 창고(storage)에서 2칸 간격으로 물건을 집어오는 규칙이 생긴 상태.”


4. view · reshape · contiguous: “복사 vs 해석 변경”의 핵심 도구

4-1) view()란?

같은 storage를 그대로 두고, shape/stride만 바꿔서 다른 텐서처럼 ‘바라보는(view)’ 것

예:

a = torch.arange(6)          # shape (6,)
b = a.view(2,3)              # shape (2,3)

데이터를 옮긴 게 아니라, “6개를 2×3으로 해석”한 것.

  • view가 가능하다는 건 “주소 계산 규칙(선형/규칙적)이 유지되어 복사가 필요 없다”에 가깝다.

4-2) reshape()는 view일 수도 있고 copy일 수도 있다

if view로 가능:
    view 반환
else:
    contiguous copy 만든 후 reshape
  • view()무조건 view만 시도 → 안 되면 에러
  • reshape()view 시도 → 안 되면 내부적으로 복사해서라도 모양을 맞춤

즉:

view  : 되면 OK / 안 되면 실패
reshape: 되면 view / 안 되면 copy

4-3) contiguous()는 무엇인가?

non-contiguous 텐서를 새로운 연속 메모리에 복사해서 contiguous 텐서로 만드는 함수

즉:

  • .t()[:, ::2] 같은 연산으로 stride가 “점프하는” 텐서가 생겼을 때,
  • 연속 메모리가 필요한 연산을 하려면 .contiguous()로 “창고 정리”를 한다.

4-4) “왜 대부분 slicing/transpose는 복사 안 하고 stride만 바꾸나?”

성능과 메모리 때문이다.

  • transpose마다 복사하면: 메모리 폭증 + 시간 폭증
  • slicing마다 복사하면: 중간 텐서가 너무 많아짐

그래서 PyTorch의 철학은:

가능하면 view(해석 변경)로 버티고,
정말 필요할 때만 copy(contiguous)한다.


4-5) “연속 메모리가 필요한 연산이 아닌 경우엔 contiguous 없이도 계산 가능?”

대체로 그렇다.

  • 많은 연산은 non-contiguous 입력도 처리할 수 있다.
  • 다만 연산/커널 구현에 따라 내부에서 임시 복사가 생길 수도 있다.

가장 대표적으로:

  • view()는 보통 contiguous를 요구(특히 평탄화 등)
  • reshape()는 상황에 따라 복사할 수도 있음

5. GPU 관점: thread · warp · memory transaction · coalescing

“일꾼 비유”로 설명
1) “GPU 병렬 계산 = 연속 메모리가 있어야만 가능한가?” + 스레드는 일꾼인가?
✅ 결론

GPU가 계산을 “못 하는” 조건은 아님.
비연속 메모리여도 계산은 할 수 있어.

다만 성능이 크게 떨어질 수 있음.
GPU는 메모리 접근이 병목이 되는 경우가 많아서, 연속적(규칙적)으로 읽을수록 빠르다.

스레드(thread) = 일꾼 

하나의 커널(kernel, GPU에서 실행되는 함수)이 실행되면

엄청 많은 스레드가 생성되고

보통 “각 스레드가 배열의 한 원소(혹은 몇 원소)”를 담당.

✅ “스레드들이 요청한 메모리 접근을 하드웨어가 묶어서 처리한다”

비유로 다시 정리

스레드 = 택배 주문자

메모리 = 창고

warp = 같은 시간대에 주문한 32명

32명이:

1번~32번 칸 물건을 주문 → 한 번에 창고에서 쓸어담음 (coalesced)

1번, 100번, 10000번… 주문 → 여러 번 왔다갔다 (느림)

-------------------------------------
스레드는 “사람(일꾼)”에 가까운 실행 단위라서,

스레드마다 레지스터(임시 변수 저장 공간)

스레드 스택/로컬 메모리
같은 게 있을 수는 있다.

하지만 우리가 coalescing 이야기할 때 말하는 “addr”은:

'스레드가 읽으려는 ‘데이터의 주소’다.

즉:

“스레드의 주소”가 아니라

“스레드가 접근하는 데이터 주소”다.

📌 정리

스레드: 주소를 계산한다
데이터: 주소를 갖고 있다

📌 핵심

코얼레싱은 “프로그래머가 직접 하는 작업”이 아니라
GPU 하드웨어가 주소 패턴을 보고 자동으로 해주는 최적화다.

5-1) 스레드는 “메모리 주소를 가진 존재”인가?

혼동 포인트 정리:

  • 데이터는 메모리에 저장되므로 주소를 “가진다”
  • 스레드는 실행 단위로서 “주소를 계산하고 그 주소의 데이터를 읽는(load) 역할”을 한다

coalescing에서 말하는 addr는:

“스레드 자신이 가진 주소”가 아니라
스레드가 읽으려는 데이터의 주소다.


5-2) “스레드가 어떤 주소를 읽을지”는 누가 정하나?

GPU 커널 코드가 정한다.

  • 각 스레드에는 thread_id가 있고
  • 코드가 i = base + thread_id * something처럼 인덱스를 계산한다
  • 그 인덱스에 해당하는 주소를 읽는다

즉 스레드는:

“내가 담당할 데이터 인덱스(i)를 계산 → 그 데이터 주소를 요청(load/store)”
을 수행한다.


5-3) warp란?

(주로 NVIDIA 기준으로) 32개의 스레드를 묶어 실행하는 단위.

  • GPU는 스레드를 1개씩 독립적으로 실행하는 느낌이 아니라
  • warp(32개) 단위로 같은 명령을 병렬로 수행(SIMT)에 가깝다.

5-4) memory transaction / cache line 단위로 읽는다는 건?

GPU가 메모리를 읽을 때는 보통 “1바이트씩” 읽는 게 아니라
덩어리(예: 32B, 64B, 128B 같은 구간)로 가져온다.

여기서 중요한 건:

warp의 32개 스레드가 요청한 주소들이
적은 수의 “덩어리” 안에 모여 있으면 빠르다.


5-5) coalescing은 무엇인가?

warp의 스레드들이 요청한 메모리 주소 패턴을 보고,
하드웨어가 이를 가능한 적은 수의 memory transaction으로 묶어 처리하는 것

  • 프로그래머가 “묶어라”라고 직접 조작하는 게 아니라
  • 하드웨어가 주소 패턴을 보고 자동으로 최적화한다

5-6) stride=1/2/4/random이 왜 성능을 가른다 (시각화)

아래는 이해를 위한 단순화(예: 메모리의 데이터가 float 숫자 형태 행렬, float32=4B, warp=32).

Case A: stride=1 (연속 인덱싱)

thread t가 a[t]를 읽는다고 하자.

  • a[0] 주소: 0
  • a[1] 주소: 4
  • a[2] 주소: 8
  • ...
  • a[31] 주소: 124

즉 0~124 바이트 구간(약 128B)에 “warp가 필요한 데이터”가 거의 다 모인다.
→ transaction을 적게 한다 → 빠르다.

Case B: stride=2

thread t가 a[2t]를 읽음, ex)슬라이싱을 거친 경우

b = a[::2]

논리적 b:
[b0] [b1] [b2] [b3]
 ↓    ↓    ↓    ↓
a[0] a[2] a[4] a[6]
물리 메모리는 그대로 연속

index → address 규칙만 바뀜
address = base + index × 2 × sizeof(float)
  • 주소: 0, 8, 16, ..., 248
    범위가 0~248로 넓어짐(대략 256B)
    → transaction 수가 늘 가능성이 커짐 → 보통 더 느림

Case C: stride가 커질수록

warp가 접근하는 주소 범위가 더 넓어져서
transaction이 더 쪼개진다.

Case D: random(불규칙)

주소들이 사방에 흩어져 있으면
warp의 요청을 묶기 어렵고 transaction이 많이 발생한다. → 느리다.

핵심 결론: “stride가 1이면 무조건 1번 트랜잭션” 같은 단정이 아니라,
요청 주소들이 몇 개의 덩어리 안에 들어가느냐가 핵심이다.
stride가 작고 규칙적일수록 “들어갈 가능성”이 높다.


6. advanced indexing은 왜 copy를 만들까?

6-1) slicing/transpose는 stride로 표현 가능(규칙적, affine)

  • a[:, 1:5]
  • a[:, ::2]
  • a.t()

이런 건 모두:

“주소 = base + is0 + js1 …” 형태로 규칙적으로 계산 가능
→ view(복사 없이)로 만들 수 있음


6-2) advanced indexing은 gather(임의 주소)라 규칙이 깨진다

예:

idx = torch.tensor([2, 0, 2, 1])
y = a[idx]

이건 접근이:

  • a[2], a[0], a[2], a[1] … 처럼 임의 순서

이 패턴은 “단일 stride 규칙”으로는 표현이 어렵다.
그리고 이런 불규칙 접근은 이후 GPU 계산에서도 coalescing이 깨질 가능성이 크다.

그래서 PyTorch는 보통:

한 번 copy해서 연속 텐서로 만들어 반환
→ 이후 연산이 더 유리해지는 경우가 많다.

또한 쓰기(assignment)에서 중복 인덱스가 있으면 의미론이 복잡해지므로(어떤 순서로 써야 하는가 등),
“뷰”로 다루기보다 “새 텐서”로 다루는 게 안전하고 일관적이다.


7. NumPy vs PyTorch Tensor: 둘 다 배열인데 왜 다르나?

공통점

  • 다차원 배열
  • shape/stride 개념
  • view 기반 slicing/transpose가 가능

PyTorch Tensor가 더 “딥러닝/학습”에 특화된 이유

  1. GPU 지원 (.to('cuda'))
  2. 자동미분(Autograd) (requires_grad=True)
  3. 딥러닝 커널(Conv/Attention/Optimizer 등) 생태계

NumPy는 무엇에 강한가?

  • CPU 기반 과학계산/데이터 처리
  • 파이썬 수치 계산의 기본 표준 라이브러리

한 문장:

NumPy는 “CPU 수치계산의 표준 배열”,
PyTorch Tensor는 “미분+GPU+딥러닝 커널까지 포함한 계산 단위”다.


# torch.Tensor는 다차원 배열이므로 (행, 열)을 튜플로 받아 한 번에 인덱싱할 수 있다.
# a[0][1] : 0번째 행 텐서를 먼저 얻은 뒤, 그 결과에서 1번째 열을 다시 인덱싱
# a[0, 1] : (0행, 1열)을 동시에 지정하는 다차원 인덱싱 (Tensor의 본질적인 방식)

print(a[0, 1])

# 파이썬 리스트는 '다차원 배열'이 아니라 '리스트 안에 리스트가 들어있는 중첩 구조'라서
# list[0,1]처럼 튜플을 (행,열)로 해석하는 인덱싱을 지원하지 않는다.

stride/contiguous 개념:

# Tensor는 storage(메모리) + shape + stride로 구성된다.
# transpose/slicing은 보통 데이터를 복사하지 않고 stride만 바꿔 "해석"을 바꾼다.
# 연속 메모리가 필요한 상황(view 등)에서는 contiguous()로 실제 복사가 발생할 수 있다.

9. 실전 체크리스트 (헷갈릴 때 바로 점검)

  1. x.shape
  2. x.stride()
  3. x.is_contiguous()
  4. x = x.contiguous()가 필요한 상황인지?
  5. view()는 “무조건 view만”, reshape()는 “view 시도 후 필요 시 copy”
  6. advanced indexing은 대체로 copy를 만든다(결과 텐서가 새로 생김)

정리

PyTorch는 가능하면 데이터를 안 옮기고(shape/stride로 해석만 바꾸는 view 전략) 버티며,
그 규칙성은 GPU가 warp 단위로 메모리 접근을 묶어(coalescing) 빠르게 처리하는 패턴과 맞물린다.
규칙이 깨지는 순간(advanced indexing 등)엔 copy로 연속 텐서를 재구성(새로운 메모리에 연속하게 배치되도록 copy해오는 것)해 이후 연산을 유리하게 만든다.

텐서 데이터 → dtype 확인 → float(4B) → thread0는 a[0], thread1은 a[1] → 주소 차이 4 → warp 32개면 128B → (128B 트랜잭션이면) 한 번에 가능

[메모리 데이터]

[스레드들이 각각 '내가 처리할 인덱스'를 배정받음]

[GPU가 스레드들을 warp 단위(32개 thread)로 묶어서 실행]

[warp에 속한 스레드들이 읽는 메모리 주소들을 보고]

[가능하면 한 번에 묶어서(coalescing) 메모리에서 gpu로 가져옴]

향후

  • stride = random 인경우
profile
지금까지 해온 여러 활동들을 간략하게라도 정리해보고자 합니다.

0개의 댓글