GPU 시스템 03 - Memory Hierarchy와 Bandwidth
GPU 성능을 좌우하는 메모리 계층과 bandwidth 병목을 어떻게 봐야 하는지
계산보다 메모리가 더 큰 문제일 때가 많다
GPU를 처음 공부하면 코어 수나 FLOPS에 먼저 눈이 간다. 하지만 실제 커널 최적화에서는 계산량보다 메모리 이동이 더 큰 병목이 되는 경우가 많다. 특히 딥러닝 연산에서는 데이터를 읽고 쓰는 방식이 성능을 거의 결정해버리는 경우가 흔하다.
그래서 GPU를 이해할 때는 연산 유닛보다 먼저 메모리 계층을 보는 편이 낫다.
가장 바깥에 있는 global memory
global memory는 용량이 크고 모든 thread가 접근할 수 있다. 하지만 느리다. 정확히 말하면 계산 장치의 속도에 비해 접근 비용이 크다.
이 때문에 단순히 데이터를 global memory에서 읽고 연산하고 다시 global memory에 쓰는 패턴만 반복하면 GPU를 쓰더라도 기대만큼 빨라지지 않는다.
실무적으로는 많은 커널이 여기서 막힌다. 계산 자체는 어렵지 않은데, 필요한 데이터를 계속 바깥에서 끌고 오느라 시간이 다 가는 것이다.
이때 중요한 것은 absolute한 "메모리가 느리다"는 사실보다, 연산 장치의 속도와 비교했을 때 메모리 대기가 상대적으로 너무 크다는 점이다. GPU는 계산을 정말 빠르게 밀어붙일 수 있기 때문에, 메모리 접근이 조금만 비효율적이어도 곧바로 병목처럼 드러난다.
Shared memory는 block 내부의 빠른 작업 공간이다
shared memory는 같은 block 안의 thread들이 함께 쓰는 작은 고속 메모리다. global memory보다 훨씬 빠르게 접근할 수 있기 때문에, 반복해서 재사용할 데이터를 올려두는 데 유용하다.
대표적인 예가 tiled matrix multiply다. 행렬의 일부 tile을 shared memory로 올려두고, block 내부 thread들이 그 값을 재사용하면 global memory 접근 횟수를 크게 줄일 수 있다.
하지만 shared memory도 공짜는 아니다.
- 용량이 작다
- bank conflict를 조심해야 한다
- 많이 쓰면 occupancy에 영향을 준다
즉, 빠르다고 무조건 많이 쓰는 것이 아니라, 재사용 가치가 큰 데이터를 올리는 식으로 판단해야 한다.
이 지점이 중요하다. shared memory는 "무조건 쓰면 좋은 빠른 메모리"가 아니라, block 내부 협업과 재사용을 명시적으로 설계할 때 빛나는 공간이다. 재사용이 거의 없다면 옮기는 비용과 synchronization 부담만 추가될 수도 있다.
Register는 가장 가깝지만 가장 제한적이다
register는 thread 개인이 사용하는 가장 가까운 저장 공간이다. 보통 가장 빠르다. 문제는 개수가 제한적이라는 점이다.
register를 지나치게 많이 쓰는 커널은 register pressure가 커지고, 그 결과 동시에 실행 가능한 warp 수가 줄어들 수 있다. 그래서 코드상으로는 좋은 최적화처럼 보이는데 실제로는 occupancy를 떨어뜨리는 일이 생긴다.
이 지점에서 중요한 감은 하나다. local optimization이 전체 성능 최적화와 같지 않다는 것이다.
즉, thread 하나가 intermediate를 더 많이 들고 있는 것이 좋아 보여도, 그 결과 SM 전체에서 resident warp가 줄어들면 전체 throughput은 오히려 나빠질 수 있다. GPU 최적화가 항상 trade-off처럼 느껴지는 이유가 바로 여기 있다.
Bandwidth를 어떻게 이해해야 할까
bandwidth는 단위 시간당 얼마나 많은 데이터를 옮길 수 있는지를 뜻한다. GPU에서는 이 값이 사실상 상한선처럼 작동하는 경우가 많다.
예를 들어 softmax 같은 연산은 FLOPS가 엄청 큰 연산처럼 보이진 않지만, 메모리를 여러 번 읽고 쓰는 구조 때문에 bandwidth-bound가 되기 쉽다. 이런 경우 계산을 조금 더 줄이는 것보다 메모리 왕복을 줄이는 것이 훨씬 큰 효과를 낸다.
그래서 GPU 성능을 볼 때는 "연산량이 많아 보이니 compute-bound겠지" 같은 감이 자주 틀린다. 실제로는 arithmetic intensity와 메모리 왕복 구조를 같이 봐야 한다. 이 감각이 이후 profiling과 roofline 관점으로 이어진다.
실무에서 자주 나오는 질문
GPU 메모리 계층을 공부할 때는 아래 질문을 습관처럼 던지는 편이 좋다.
- 이 데이터는 global memory에서 몇 번 읽히는가?
- block 내부에서 shared memory로 재사용할 가치가 있는가?
- register 사용이 지나쳐 occupancy를 깎고 있지는 않은가?
- 지금 병목은 연산인가, bandwidth인가?
이 질문이 자연스러워지면 커널을 읽는 눈이 달라진다.
한 가지 예로 보기
예를 들어 naive matrix multiply를 생각해보자. 각 thread가 필요한 값을 global memory에서 계속 읽으면 같은 데이터가 여러 번 중복 로드된다. 반면 tile 단위로 shared memory에 올리면 block 내부 thread들이 그 값을 함께 재사용할 수 있다.
겉으로 보기에는 연산 수는 비슷해 보일 수 있다. 하지만 실제 성능은 메모리 접근 패턴 때문에 크게 갈린다. 이것이 GPU에서 메모리 계층이 중요한 이유다.
중요한 결론
GPU 최적화는 종종 "어떻게 더 많이 계산할까"보다 "어떻게 덜 옮길까"에 가깝다. 이 감각이 없으면 CUDA나 Triton 코드를 봐도 왜 특정 최적화가 의미 있는지 잘 안 보인다.
다음 글에서는 실제 CUDA kernel을 작성할 때 thread indexing, launch configuration, block 크기를 어떻게 잡아야 하는지 본다.