2. View의 작동 원리 이해하기

PyTorch 공부를 하던 도중, 퀴즈를 풀다가 틀렸는데 아래 지문이 옳은 지문이라는 사실을 알고 나서 조금 놀랐었다.

당시 퀴즈

t = Tensor(3, 2) 이고, s = t[:2, :1] 일 때, view 를 사용해 s의 모양을 변경할 수 있다.

나는 s가 contiguous하지 않으니 당연히 view가 안 될 거라 생각해서 거짓이라고 답했는데, 실제로는 view가 되는 참인 지문이었다. 그래서 공부를 더 하다가 Tensor.view()의 구체적인 동작 원리에 대해서 알게 되었고, 그 부분을 조금 더 깊게 정리해보려고 한다.

view는 zero-copy 연산#

먼저 view의 정체부터 알아보자.

view()는 데이터를 복사하지 않고, stride와 shape 정보만 바꿔서 텐서를 다른 모양으로 보여주는 zero-copy 연산이다.

zero-copy 연산이라는 것은 메모리에 들어있는 값 자체는 그대로 두고, 어떻게 읽을지에 대한 정보(shape, stride)만 갈아끼우는 것이다. 그래서 빠르고 메모리를 추가로 먹지 않는다. 대신 아무 모양으로나 되는 건 아니고 조건이 있다.

Stride는 메모리 위를 걷는 보폭#

stride를 이해하면 view의 조건이 자연스럽게 따라온다.

  • Stride는 메모리 위의 주소를 걷는 보폭으로 이해하면 된다.
  • 예를 들어 stride(2, 1)은, dim 0 방향으로 한 칸 갈 때 보폭 2, dim 1 방향으로 한 칸 갈 때 보폭 1이라는 뜻이다.
  • 원본 Tensor의 메모리 배열과 논리적 순서가 동일하지 않으면 contiguous == False가 된다.

여기서 내가 헷갈렸던 게 있다. contiguous하지 않으면 무조건 view가 안 되는 줄 알았는데, 그게 아니었다.

view()의 진짜 조건

contiguous하지 않아도, 새로운 모양으로 읽었을 때 그 순서가 일정한 stride 튜플로 표현될 수 있으면 view()를 쓸 수 있다. stride 튜플로 표현이 가능하다는 것은, 메모리를 읽을 때 일관된 보폭으로 읽을 수 있다는 것이다.

반대로, 새로운 순서로 읽었을 때 보폭이 일정하지 않아서 view().stride()가 깔끔한 튜플 하나로 딱 떨어지지 않으면, 그 텐서는 view()를 사용할 수 없다.

uncontiguous하지만 view()가 되는 예#

말로만 하면 잘 와닿지 않아서 직접 만들어봤다.

ts = torch.arange(16).view(4, 4)
# tensor([[ 0,  1,  2,  3],
#         [ 4,  5,  6,  7],
#         [ 8,  9, 10, 11],
#         [12, 13, 14, 15]])

# 열을 한 칸씩 건너뛰어 슬라이싱 -> contiguous하지 않은 텐서
uncontiguous_ts = ts[:, ::2]
# tensor([[ 0,  2],
#         [ 4,  6],
#         [ 8, 10],
#         [12, 14]])

uncontiguous_ts는 메모리상에서 0, 2, 4, 6, 8, 10, 12, 14 위치의 값들을 가리킨다. 원본 메모리 기준으로 보면 보폭 2로 일정하게 떨어져 있다. 그래서 view가 된다.

uncontiguous_ts.view(1, 8)
# tensor([[ 0,  2,  4,  6,  8, 10, 12, 14]])

uncontiguous_ts.view(1, 8).stride()
# (16, 2)

uncontiguous_ts.view(2, 4)
# tensor([[ 0,  2,  4,  6],
#         [ 8, 10, 12, 14]])

uncontiguous_ts.view(2, 4).stride()
# (8, 2)

view(1, 8).stride()(16, 2), view(2, 4).stride()(8, 2)처럼 깔끔한 튜플로 표현된다. 새로운 순서로 읽어도 보폭이 일정하기 때문이다. 이게 view가 가능한 이유다.

view()가 안 되는 예#

그럼 언제 안 될까? 비슷한 슬라이싱처럼 보여도 보폭이 일정하지 않으면 안 된다. 이번엔 행과 열을 둘 다 건너뛰어 봤다.

# 행도 열도 한 칸씩 건너뛰기
ts[::2, ::2]
# tensor([[ 0,  2],
#         [ 8, 10]])

ts[::2, ::2].stride()
# (8, 2)

ts[::2, ::2].view(4)
# RuntimeError: view size is not compatible with input tensor's size and
# stride (at least one dimension spans across two contiguous subspaces).
# Use .reshape(...) instead.

ts[::2, ::2]가 가리키는 값들은 메모리상에서 0, 2, 8, 10 위치다. 0→2는 보폭 2인데 2→8은 보폭 6이라 보폭이 일정하지 않다. 그래서 1차원으로 쭉 편 모양을 일정한 stride 하나로 표현할 수 없고, 그래서 view가 막힌다. (이럴 때 reshape를 쓰면 copy를 해서라도 모양을 바꿔준다.)

transpose도 같은 이유로 막힌다.

ts.t().stride()   # (1, 4)
ts.t().view(16)   # 같은 RuntimeError
정리

view 가능 여부는 contiguous한가가 아니라, 새 모양으로 읽었을 때 view().stride()가 일정한 튜플로 떨어지는가로 보는 게 더 정확하다.

참고#

view vs reshape#

reshapeview()가 가능할 때는 view처럼 동작하고, 가능하지 않을 때만 tensor.contiguous()로 메모리를 정리한 뒤 copy를 한다.

따라서 위처럼 view가 안 되는 특정 상황이 아니면 성능 차이는 없으며, 안전하게 reshape를 써도 된다.

expand vs repeat#

expand는 실제로 메모리를 새로 먹는 게 아니라, 차원이 1인 구조를 반복해서 읽기만 하는 것이다. 반면 repeat은 실제로 메모리를 복사한다.

이것도 stride로 확인할 수 있다. expand한 텐서의 stride()를 보면, 반복되는 차원의 stride가 0으로 찍힌다. 보폭이 0이라는 건 같은 자리를 계속 읽는다는 뜻이다.

x = torch.tensor([[1], [2], [3]])   # shape: (3, 1)

x.expand(3, 4)
# tensor([[1, 1, 1, 1],
#         [2, 2, 2, 2],
#         [3, 3, 3, 3]])

x.expand(3, 4).stride()
# (1, 0)   <- 반복되는 dim의 stride가 0

그래서 메모리를 아끼고 싶으면 expand, 진짜 복사본이 필요하면 repeat을 쓰면 된다.

댓글