처음엔 이렇게 시작한다.
a[0][1]은 되는데 a[0,1]은 안 됨a[0,1]이 됨stride, contiguous, view, transpose가 나오면 갑자기 메모리 이야기가 끼어듦이 글의 목표는 한 줄이다:
Tensor의 인덱싱/변형은 “데이터를 움직이는 것”이 아니라, 대부분 “메모리를 해석하는 규칙(shape/stride)을 바꾸는 것”이며,
이 규칙성은 GPU가 빠르게 처리하기 좋은 패턴과 강하게 연결된다.
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)을 인덱스로 넣은 것으로 해석된다. 리스트는 그 튜플을 “(행,열)”로 해석하는 로직이 없다.
a = torch.tensor([[1,2,3],
[4,5,6],
[7,8,9]])
Tensor는 내부적으로 shape=(3,3) 같은 “다차원 구조 정보”를 갖는 단일 객체다.
그래서 a[0,1]은 “튜플로 (행,열)”을 주는 게 자연스럽다.
정확한 표현: “Tensor는 다차원 배열이라 튜플 인덱싱이 가능하다.”
부정확한 표현: “튜플 인덱싱이 더 빨라서 좋다.” (대부분의 경우 ‘속도’가 핵심이 아니라 ‘구조’가 핵심, 다차원 배열이니 한번에 행열 정보 전달 하는 것이 자연스럽다)
Tensor를 이해하는 핵심은 이 4가지:
메인 메모리는 보통 바이트 주소(byte-addressable)다.
a[0]의 첫 바이트 주소가 1000이면a[1]의 첫 바이트 주소는 1004가 된다.“4바이트 데이터의 주소”는 보통 그 4바이트 덩어리의 첫 바이트 주소를 말한다.
Tensor에서 “원소 (i,j)”가 실제 메모리에서 어디에 있는지 계산은:
여기서 초심자가 가장 많이 오해하는 포인트가 있다.
❌ 보통 그런 뜻이 아니다.
✅ 더 정확히는:
non-contiguous 텐서 = storage는 연속 메모리지만,
텐서가 그 storage를 “연속적으로 읽히는 형태”로 해석하지 않는 상태
즉, stride 규칙이 “연속 접근에 불리한(점프하는)” 형태다.
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) 의미:
즉:
a[i, j]는 index = i*3 + j*1
논리적으로는:
b =
[[1,4,7],
[2,5,8],
[3,6,9]]
하지만 복사 없이 “해석 규칙만 바꾸면” 된다.
전치의 핵심:
그래서 b.stride() = (1,3).
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가 된다.
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):
이제:
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]로 이동그래서 b.stride()는 (4,2)가 된다.
비유 보정: “데이터가 여기저기 흩어진 게 아니라,
같은 창고(storage)에서 2칸 간격으로 물건을 집어오는 규칙이 생긴 상태.”
같은 storage를 그대로 두고, shape/stride만 바꿔서 다른 텐서처럼 ‘바라보는(view)’ 것
예:
a = torch.arange(6) # shape (6,)
b = a.view(2,3) # shape (2,3)
데이터를 옮긴 게 아니라, “6개를 2×3으로 해석”한 것.
if view로 가능:
view 반환
else:
contiguous copy 만든 후 reshape
view()는 무조건 view만 시도 → 안 되면 에러reshape()는 view 시도 → 안 되면 내부적으로 복사해서라도 모양을 맞춤즉:
view : 되면 OK / 안 되면 실패
reshape: 되면 view / 안 되면 copy
non-contiguous 텐서를 새로운 연속 메모리에 복사해서 contiguous 텐서로 만드는 함수
즉:
.t()나 [:, ::2] 같은 연산으로 stride가 “점프하는” 텐서가 생겼을 때,.contiguous()로 “창고 정리”를 한다.성능과 메모리 때문이다.
그래서 PyTorch의 철학은:
가능하면 view(해석 변경)로 버티고,
정말 필요할 때만 copy(contiguous)한다.
대체로 그렇다.
가장 대표적으로:
view()는 보통 contiguous를 요구(특히 평탄화 등)reshape()는 상황에 따라 복사할 수도 있음“일꾼 비유”로 설명
1) “GPU 병렬 계산 = 연속 메모리가 있어야만 가능한가?” + 스레드는 일꾼인가?
✅ 결론
GPU가 계산을 “못 하는” 조건은 아님.
비연속 메모리여도 계산은 할 수 있어.
다만 성능이 크게 떨어질 수 있음.
GPU는 메모리 접근이 병목이 되는 경우가 많아서, 연속적(규칙적)으로 읽을수록 빠르다.
스레드(thread) = 일꾼
하나의 커널(kernel, GPU에서 실행되는 함수)이 실행되면
엄청 많은 스레드가 생성되고
보통 “각 스레드가 배열의 한 원소(혹은 몇 원소)”를 담당.
✅ “스레드들이 요청한 메모리 접근을 하드웨어가 묶어서 처리한다”
비유로 다시 정리
스레드 = 택배 주문자
메모리 = 창고
warp = 같은 시간대에 주문한 32명
32명이:
1번~32번 칸 물건을 주문 → 한 번에 창고에서 쓸어담음 (coalesced)
1번, 100번, 10000번… 주문 → 여러 번 왔다갔다 (느림)
-------------------------------------
스레드는 “사람(일꾼)”에 가까운 실행 단위라서,
스레드마다 레지스터(임시 변수 저장 공간)
스레드 스택/로컬 메모리
같은 게 있을 수는 있다.
하지만 우리가 coalescing 이야기할 때 말하는 “addr”은:
'스레드가 읽으려는 ‘데이터의 주소’다.
즉:
“스레드의 주소”가 아니라
“스레드가 접근하는 데이터 주소”다.
📌 정리
스레드: 주소를 계산한다
데이터: 주소를 갖고 있다
📌 핵심
코얼레싱은 “프로그래머가 직접 하는 작업”이 아니라
GPU 하드웨어가 주소 패턴을 보고 자동으로 해주는 최적화다.
혼동 포인트 정리:
coalescing에서 말하는 addr는:
“스레드 자신이 가진 주소”가 아니라
스레드가 읽으려는 데이터의 주소다.
GPU 커널 코드가 정한다.
thread_id가 있고i = base + thread_id * something처럼 인덱스를 계산한다즉 스레드는:
“내가 담당할 데이터 인덱스(i)를 계산 → 그 데이터 주소를 요청(load/store)”
을 수행한다.
(주로 NVIDIA 기준으로) 32개의 스레드를 묶어 실행하는 단위.
GPU가 메모리를 읽을 때는 보통 “1바이트씩” 읽는 게 아니라
덩어리(예: 32B, 64B, 128B 같은 구간)로 가져온다.
여기서 중요한 건:
warp의 32개 스레드가 요청한 주소들이
적은 수의 “덩어리” 안에 모여 있으면 빠르다.
warp의 스레드들이 요청한 메모리 주소 패턴을 보고,
하드웨어가 이를 가능한 적은 수의 memory transaction으로 묶어 처리하는 것
아래는 이해를 위한 단순화(예: 메모리의 데이터가 float 숫자 형태 행렬, float32=4B, warp=32).
thread t가 a[t]를 읽는다고 하자.
a[0] 주소: 0a[1] 주소: 4a[2] 주소: 8a[31] 주소: 124즉 0~124 바이트 구간(약 128B)에 “warp가 필요한 데이터”가 거의 다 모인다.
→ transaction을 적게 한다 → 빠르다.
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)
warp가 접근하는 주소 범위가 더 넓어져서
transaction이 더 쪼개진다.
주소들이 사방에 흩어져 있으면
warp의 요청을 묶기 어렵고 transaction이 많이 발생한다. → 느리다.
핵심 결론: “stride가 1이면 무조건 1번 트랜잭션” 같은 단정이 아니라,
요청 주소들이 몇 개의 덩어리 안에 들어가느냐가 핵심이다.
stride가 작고 규칙적일수록 “들어갈 가능성”이 높다.
a[:, 1:5]a[:, ::2]a.t()이런 건 모두:
“주소 = base + is0 + js1 …” 형태로 규칙적으로 계산 가능
→ view(복사 없이)로 만들 수 있음
예:
idx = torch.tensor([2, 0, 2, 1])
y = a[idx]
이건 접근이:
이 패턴은 “단일 stride 규칙”으로는 표현이 어렵다.
그리고 이런 불규칙 접근은 이후 GPU 계산에서도 coalescing이 깨질 가능성이 크다.
그래서 PyTorch는 보통:
한 번 copy해서 연속 텐서로 만들어 반환
→ 이후 연산이 더 유리해지는 경우가 많다.
또한 쓰기(assignment)에서 중복 인덱스가 있으면 의미론이 복잡해지므로(어떤 순서로 써야 하는가 등),
“뷰”로 다루기보다 “새 텐서”로 다루는 게 안전하고 일관적이다.
.to('cuda'))requires_grad=True)한 문장:
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()로 실제 복사가 발생할 수 있다.
x.shapex.stride()x.is_contiguous()x = x.contiguous()가 필요한 상황인지?view()는 “무조건 view만”, reshape()는 “view 시도 후 필요 시 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로 가져옴]
향후