# 들어가며
CPU와 GPU의 구조에 대해서 자세하게 배운다. CPU 구조를 조금 더 알아보고 CPU Performance Optimization을 토대로 GPU Performance Optimization을 중점적으로 알아본다. 컴퓨터 구조 관련한 배경지식이 있으면 이해하기 편하므로 글을 읽다가 이해가 되지 않는다면 관련 블로그 포스트나 유튜브를 찾아보는 것이 좋다. 이전 글은 다음 링크의 블로그 포스트를 참고하면 된다.
# Processors and Programs
## What is Program?
프로세서의 입장에서 프로그램이란, 명령어들(Instructions)의 연속이다. 프로세서가 프로그램 카운터 (PC)에 의해서 참조된 명령어를 실행하고, 명령어를 실행하면 레지스터, 메모리, CPU의 상태와 같은 기계의 상태들이 변경된다.
## Whait is Processor?
프로세서란 Instruction을 사용해서 유용한 작업을 하는 모듈이다. Add, Subtract와 같은 사칙연산부터 메모리 접근까지 다양한 Instruction이 존재할 것이다. 이러한 Instruction의 파이프라인을 따르는 일반적인 실행 순서는 다음과 같다.
- Instruction Fetch
명령어를 메모리에서 읽어온다. 이때, 메모리에 접근한다. - Instruction Decode
명령어가 무슨 의미인지 해석한다. - Instruction Execute
해석된 명령어를 정해진 방법에 따라서 실행한다. - Write-back Result
실행한 결과를 메모리에 저장해서 반영한다.
## What is Latency?
동작을 수행하는데 걸린 시간. 한국어로는 지연시간이라고 하며, Speed라고도 한다. 작업을 얼마나 빠르게 할 수 있는지 판단하는 기준으로 많이 사용된다. CPU 뿐만이 아니라 Electric Engineering, Computer Science 전반에 많이 사용되는 용어이다.
## What is Throughput?
단위 시간당 실행된 동작의 수 또는 생성된 결과의 수. 주어진 시간 내에 얼마나 많은 작업을 완료할 수 있는가를 판단하는 기준이다. 컴퓨터 네트워크에서도 굉장히 자주 사용되는 용어로, 단위는 분야마다 천차만별인 것으로 알고 있다.
CPU는 Instruction을 최대한 빠르게 처리 하는 것에 초점이 맞추어져서 설계되었다. Latency-optimized 라고하며, 단일 스레드 성능에서 뛰어나도록 설계되었다. 당연한것이, CPU는 사용자가 입력을 주면 최대한 빠르게 사용자에게 대답을 내놓을 수 있어야 하는 기계이다. 그것이 Computer의 존재의 이유이기도 하다.
## CPU Performance Optimizations
CPU의 성능을 최대한으로 끌어올리기 위해서 여러가지 기법들이 연구되었고, 지금도 연구되고 있다. 몇가지를 알아보자.
1. Pipeline
파이프라인은 CPU 내부에서 Instruction을 처리하는데 몇가지의 단계로 구분된다는 것을 활용한다. Instruction 0번이 가장 먼저 실행되면 Fetch 단계에 속할 것이다. 그 다음 사이클에서 Instuction 1번이 Fetch 단계로 바로 들어가고 Instruction 0번은 Decode 단계에 속한다. 이처럼 하나의 Instruction이 모두 끝날때 까지 기다리는 것이 아니라 CPU 내부에서 가능한 작업이 비어있지 않도록 하는 것이 Pipeline이다.
2. Caches
캐시는 메모리 참조의 두가지 Locality 특성을 해결하고자 도입되었다. 첫번째는 Temporal Locality, 두번째는 Spatial Locality이다. Temporal Locality는 자주 참조되는 데이터가 다시 참조될 가능성이 높으므로 캐시에 저장한다 . Spatial Locality는 참조한 데이터의 주변 데이터들이 참조될 가능성이 높으므로 캐시에 저장한다. CPU는 메모리에 접근하는 것보다 캐시에 접근하는 속도가 월등하게 빠르다. 따라서, 캐시는 성능을 최적화 하는데에 굉장히 중요한 요소이다.
3. Prefetching
Prefetching은 Spatial Locality를 해결한다. 다음과 같은 코드가 있다고 가정 해보자.
for(int i=0; i<=N; i++) {
C[i] = A[i] + B[i];
}
이를 캐시하는 과정을 그림으로 나타내면 다음과 같다. A0, B0이 참조되었지만 반복문 내에서 참조되었으므로, AN, BN까지 캐시하는 것이 CPU 성능 향상 입장에서 타당 할 것이다.
4. Out-of-order Execution + Superscalars
Instruction_0 (A + B => C)
Instruction_1 (C + D => E)
Instruction_2 (F + G => H)
다음과 같은 Instruction 세개가 있다. Instruction_0의 C의 결과는 Instruction_1에서 사용되어야 한다. 즉, 둘은 Dependency가 있다. Instuction_0이 메모리에 Write-back을 할 때 까지 Instruction_1이 실행될수 없기에 파이프라인이 그만큼 손해를 보게 된다. 하지만 Instruction_0과 Instruction_2는 독립적이다 즉 Independent하다. 따라서 CPU는 실행 순서를 조절해서 0 -> 1 가 아닌, 0 -> 2로 만든다. 이것이 Out-of-order Execution이다. 이는 Superscalar의 특징중의 하나로 하드웨어로 구현된다.
Instruction-level Parallelism (ILP)를 구현하기 위한 구조인 Superscalars는 프로세서가 명령어 시퀀스에서 독립적인 명령어를 동적으로 찾아내어 병렬로 실행하는 것이다. Superscalar에 대한 자세한 얘기는 컴퓨터 구조 관련 글을 찾아보도록 하자.
# GPU Performance Optimization
## GPU Architecture
GPU의 자세한 구조는 이전 글을 참고하도록 하자. Terminology와 개념을 정확하게 알아야 Optimization을 이해할수 있다.
아래의 그림은 오랜지색 스레드가 몇번째 스레드인지 알아내는 연산 과정이다. GPU SW와 HW 모두 익숙해져야 한다.
## GPU Optimization Agenda
GPU Optimization에는 굉장히 많은 방식들이 존재한다.
Memory coalescing 최적화, 데이터 레이아웃과 재사용 최적화, 리소스 활용 극대화, 스레드 전환을 통한 Latency hiding, CUDA 리소스 제약을 관리, 호스트와 디바이스 간의 상호작용 최소화 등이 존재하는데 이 중에서 몇가지 중요한 것들을 살펴보도록 한다.
GPU에서 Global Memory에 접근할 때 Latency가 약 100 클럭 사이클 정도로 굉장히 높다. 즉 굉장히 느리다. 이를 해결하고자 할 때 Memory Coalescing이나 멀티뱅크 캐시를 사용해서 대역폭 활용도를 높인다.
Memory Coalescing은 여러번의 메모리 접근을 한번의 연속적인 접근으로 바꾸어서 메모리 접근 효율성을 높이는 방법이다. 간단하게 말하자면 여러개의 메모리 접근을 하나의 큰 요청으로 병합하는 방식이다. 더욱 자세하게 알아보자.
## Memory Coalescing
다음과 같은 아름다운 상황이 있다고 생각해보자.
동일한 warp에 있는 32개의 모든 스레드가 동일한 캐시 블록에 접근한다. 이때 상황은 두가지로 나뉜다.
Cache Hit인 경우
- Bank Conflict 즉, 서로 다른 스레드가 동일한 데이터에 접근하는 경우가 없으면, 한 사이클에 모든 L1 캐시 접근이 이루어질 것이다.
- Bank Conflict가 있으면 L1 캐시 접근이 어쩔수 없이 여러 사이클로 기다리도록 이루어질 것이다.
Cache Miss인 경우
- L2캐시 혹은 더 밑에 있는 메인 메모리에 한번 접근해서 캐싱해올 것이다.
자 이제 조금 안좋은 상황을 생각 해보자.
동일한 warp에 있는 32개의 스레드가 서로 다른 N개의 캐시 블록에 접근한다. 마찬가지로 상황은 나뉜다.
Cache Hit인 경우
- Bank Conflict가 없더라도 서로 다른 N개의 캐시 블록에 접근하므로 최소 N 사이클이 소요된다.
- Bank Conflict가 있으면 전체 캐시 접근 latency가 증가할수 있을 것이다.
Cache Miss인 경우
- L2캐시 혹은 더 밑에 있는 메인 메모리에 N번 접근해서 캐싱해올 것이다.
- 이때 Memory Coalescing을 사용할 수 있다. 여러번의 메모리 요청을 하나의 transaction으로 결합해서 메모리 접근 횟수 자체를 줄인다. 한번에 쏙쏙 많이 가져오면 되는거 아니야? 라는 생각이다. Coalescing은 병합이라는 뜻이다.
다음과 같은 상황을 생각 해보자. 하나의 워프는 32개의 스레드로 이루어져있으며, 각 스레드는 4바이트를 필요로 한다. 하나의 캐시 라인에 다음 그림과 같이 정렬되고 연속적인 메모리 접근 요청이 보내졌는데 이를 Coalesced Memory Access 라고 한다.. 워프는 당연히 4x32 = 128바이트가 필요할 것이다. 이때 하나의 캐시 라인에 해당하는 128바이트만 읽어오면 된다!
캐시 블록이 워프 크기의 배수가 되도록 보장했다고 가정하자. 일반적으로도 그렇다고 한다.
위의 상황에서 Bus Utilization은 100%이다. Bus는 메모리와 CPU사이를 연결해서 데이터가 지나다니는 통로라고 생각하면 된다.
다음과 같은 상황도 있다. 하나의 캐시 라인에 정렬되어 있지만 연속적인 접근 요청은 아니다. CUDA 1.0에서는 여러번의 메모리 접근이 필요하지만, CUDA 2.0에서부터는 이런 경우도 한번의 요청이면 된다고 한다. 이 상황에서 역시 Bus Utilization은 100%이다.
다음과 같이 메모리 접근이 정렬되어있지만 여러개의 캐시 라인에 걸쳐있는 경우이다. 이 경우에는 최소 두번의 접근이 필요하다. 128Byte 메모리를 사용하는데 256Byte의 바이트를 전부 다 읽어와야 한다. 대역폭을 낭비하는일이며 실제로도 효율이 떨어진다. Bus Utilization은 50%이다.
다음과 같이 메모리 블록을 더작은 32Byte 세그먼트로 쪼개서 생각할수도 있다. 이런 경우 캐시는 블록이 작아져서 캐시 미스가 날 확률이 높아지므로 캐시 효율이 떨어지긴 할 것이다. 하지만 Bus Utilization은 80%이다. CUDA에서는 이렇게 캐시 블록에 대한 다양한 Configuration을 지원한다. 조금의 차이가 엄청난 성능 차이로 나타날 수 있기 때문에 상황에 맞춰서 조절해서 쓰라는 뜻이다.
## Shared Memory and Bank Conflicts
이전 글에서 배웠던 것 처럼 SM안에는 스레드 사이의 데이터를 공유할 수 있는 Shared Memory가 있다. 이 Shared Memory는 안에는 Bank라는 것이 존재한다. Bank가 무엇이냐라면, 단순히 Shared Memory를 32개로 쪼갠거다. 32로 나누어서 균등하게 쪼갰을 것이다. 그렇다면 왜 쪼갰느냐? 스레드가 32개이므로, 하나의 Shared Memory에 여러개가 한번에 접근 하는 것보다 특정 위치의 뱅크로만 접근하는 것이 더 효율적이기 때문이다. 쪼개는 방식은 캐시를 Physical Memory를 쪼개는 방식이랑 비슷한 것 같다. Bank Width가 4Byte라면, 메모리 주소 1이랑 메모리 주소 129는 모두 Bank 0에 저장되어 있는 식이다.
Bank Conflict는 서로 다른 스레드가 같은 뱅크에 접근하려고 할 때 발생한다. 따라서, 데이터의 구조가 Bank Conflict를 최소화 할 수 있도록 만드는 것이 매우 중요하다.
## Latency Hiding
Latency Hiding을 설명하기 전에 먼저 배경지식을 설명할 필요가 있다. CPU와 GPU 모두 메모리에 접근해서 데이터를 가져오는 시간은 오래 걸린다. 하지만 극복하기 위해서 진화해온 방식이 다르다. CPU는 프로세스의 Latency를 최대한으로 낮추기 위해서 크고 아름다운 캐시를 CPU 칩 옆에 내장한다. GPU는 레지스터의 크기가 커서 Context Switching에 드는 코스트가 거의 제로에 가깝다.
그래서 GPU는 하나의 SM에 여러개의 워프가 있을 때, 한 워프의 작업이 끝날 때 까지 기다리는 것이 아닌, Context Switching으로 현재 작업하는 워프를 교체한다. 이렇게 되면 시간순으로 봤을 때 각각 워프의 Latency는 있을지 모르겠지만 GPU 연산 유닛 자체는 쉬는 시간이 거의 없이 항상 높은 Throughput으로 데이터를 처리한다. 이것을 Latency Hiding이라고 한다.
오른쪽 구조에 4개의 워프를 실행할 수 있는 SIMT 구조의 머신이 있다고 가정 해보자.
이때 메모리를 읽어오든 I/O가 발생하든 Stall이 생겼다. 그렇다면 다른 작동 가능한 워프를 실행시켜서 Stall을 숨긴다. 1번 워프의 Stall이 사라진건 아니지만, SM 밖에서 보면 그냥 끊임없이 Computation 하고 있는 것으로 보인다! 이는 Computations/Sec로 나타낼 수 있는 Throughput이 향상되었다고 볼 수 있다.
이렇게 최대한 많은 Warp를 만드는 것이 중요하다. 이 워프를 늘리는 방법은 더 좋은 성능의 GPU를 쓰는것 외에는 답이 없다. Register의 크기와 Shared Memory의 크기를 늘리는 것도 GPU 병렬성 성능 향상에 도움이 된다.
## Block and SM
스레드, 스레드 블록, 그리드 <-> 코어, SM, Device 이렇게 매칭을 할 수 있다. 이때, 하나의 스레드 블록이 두개 이상의 SM에 나누어서 들어갈 수는 없지만, 하나의 SM에 여러개의 스레드 블록이 들어갈 수는 있다. 이때, GPU는 하나의 SM에 최대한 많은 양의 스레드 블록을 할당하도록 한다.
국민대학교 권은지 교수님의 인공지능 하드웨어 강의를 수강하며 정리한 내용입니다.
Slides Provided
임베디드 시스템 프로그래밍 - Eunhyeok Park
딥러닝 최적화 - Eunhyeok Park
'AI > AI Hardware' 카테고리의 다른 글
[인공지능 하드웨어] 6 - Deep Learning Optimization(Convolution Lowering, Systolic Array) (6) | 2024.10.11 |
---|---|
[인공지능 하드웨어] 5 - GPU Performance Optimization(Matrix Tiling, Tensor Core) (8) | 2024.10.09 |
[인공지능 하드웨어] 3 - GPU Architecture(1) (7) | 2024.10.04 |
[인공지능 하드웨어] 2 - DNN Computation (3) | 2024.09.23 |
[인공지능 하드웨어] 1 - Introduction to DNN (3) | 2024.09.23 |