지난 Conv 연산의 3가지 매핑에서 우리는 합성곱(Convolution) 연산을 하드웨어에 최적화하기 위해 메모리 용량과 연산 속도를 맞바꾸는(Im2Col) 거대한 Trade-off를 살펴보았습니다.
CNN 가속기의 주된 연산 부하(Workload)가 Convolution에 집중되어 있다면, 아키텍처의 기능적 완전성을 위해 반드시 수반되어야 하는 필수 연산들이 존재합니다. 바로 풀링(Pooling)과 패딩(Padding)입니다.
- padding=1: “가장자리에 0 한 줄 채워줘.”
- MaxPool2d(2): “2×2 칸 중에서 제일 큰 숫자 뽑아줘.”
소프트웨어 엔지니어 입장에서 이들은 그저 옵션일 뿐입니다. 하지만 연산량(FLOPs)으로 따지면 전체 모델의 1%도 안 되는 이 간단한 작업들이, 하드웨어 아키텍트에게는 불규칙성(Irregularity)과 버퍼링(Buffering)이라는 골치 아픈 문제를 안겨줍니다.
이번 글에서는 메인 연산기(MAC) 뒤에서 조용히 칩의 면적(Area)을 잡아먹고 제어 로직을 복잡하게 만드는 범인, Pooling과 Padding의 하드웨어적 이슈를 파헤쳐 보겠습니다.
1. Padding: 존재하지 않는 데이터인 ‘0’을 처리하는 법
Zero Padding은 이미지의 크기를 유지하거나 가장자리 특징을 살리기 위해 외곽에 0을 채워 넣는 기법입니다. 문제는 “이 ‘0’을 어디서 가져올 것인가?”입니다.
소프트웨어적 방식 (메모리 낭비)
가장 쉬운 방법은 실제로 메모리(DRAM) 상에 0으로 채워진 테두리를 가진 새로운 이미지를 만드는 것입니다. 하지만 이는 엄청난 대역폭 낭비입니다. 하드웨어 엔지니어에게 “0”이라는 무의미한 데이터를 읽기 위해 비싼 DRAM 대역폭을 쓰라는 것은 용납할 수 없는 일입니다.
하드웨어적 방식 (On-the-fly Generation)
따라서 NPU는 On-the-fly (실시간 생성) 방식을 사용합니다. 메모리에는 원본 이미지만 저장해두고, 데이터를 읽어 들이는 입력 포트에서 좌표(Coordinate)를 계산하여 가짜 ‘0’을 끼워 넣습니다. 이때 복잡한 제어 로직(FSM: Finite State Machine)이 필요해집니다.
- 현재 픽셀의 좌표 (x, y)가 이미지 경계(Boundary)를 벗어났는지 매 클럭(Clock)마다 검사해야 합니다.
- 벗어났다면 메모리 읽기를 멈추고(Stall), 대신 멀티플렉서(MUX)를 통해 ‘0’ 값을 연산기로 주입합니다.
- 이 경계 검사 로직은 단순해 보이지만, 칩이 고속으로 동작할 때 타이밍(Timing) 문제를 일으키는 주범이 되기도 합니다.
2. Pooling: 스트리밍 데이터와 라인 버퍼(Line Buffer)의 딜레마
Max Pooling (2 * 2)은 4개의 픽셀 중 최댓값을 뽑는 연산입니다. 비교기(Comparator) 몇 개면 구현할 수 있는 아주 가벼운 연산처럼 보입니다. 하지만 진짜 문제는 데이터가 들어오는 순서에 있습니다.
하드웨어는 이미지를 한 번에 통째로 보는 게 아니라, TV 주사선처럼 한 줄씩(Row-by-Row) 읽어 들입니다. (이를 Raster Scan 순서라고 합니다.)
라인 버퍼 (Line Buffer)의 필요성
2 * 2 윈도우로 풀링을 하려면 첫 번째 줄(Row N)과 두 번째 줄(Row N+1)의 데이터가 동시에 필요합니다. 그런데 데이터는 한 줄씩 들어오므로, 하드웨어는 두 번째 줄이 들어올 때까지 첫 번째 줄 전체를 어딘가에 저장해두고 기다려야 합니다. 이때 필요한 메모리를 라인 버퍼(Line Buffer)라고 합니다.
이미지의 폭(Width)이 넓을수록(예: 4K 이미지), 라인 버퍼의 크기도 커져야 합니다.
- 비용 분석: 고작 비교 연산 몇 번 하겠다고, 이미지 한 줄 전체를 저장할 수 KB~수 MB의 SRAM을 할당해야 합니다. 이는 칩 면적(Area) 관점에서 상당한 오버헤드입니다.
3. 동기화(Synchronization)와 파이프라인 버블
Convolution 레이어 바로 뒤에 Pooling 레이어가 붙는 구조(Conv-Pool)는 매우 흔합니다. 여기서 데이터 속도의 불일치(Rate Mismatch) 문제가 발생합니다.
- Conv 출력: 매 클럭마다 픽셀을 뱉어냅니다. (Stride=1 가정)
- Pool (2 * 2) 입력: 2줄이 모일 때까지 기다렸다가, 2줄이 모이면 4개를 묶어 1개를 뱉어냅니다.
Pooling 유닛은 데이터를 기다리는 동안 멍하니 있어야 하고(Idle), 데이터가 모이면 순식간에 처리해야 합니다. 이 과정에서 파이프라인 흐름이 끊기는 버블(Bubble)이 발생하기 쉽습니다. 이를 막기 위해 Conv와 Pool 사이에는 FIFO(First-In-First-Out)라는 추가적인 버퍼가 또 필요합니다.
결국 간단한 연산이라던 Pooling은 라인 버퍼(SRAM) + FIFO + 복잡한 제어 로직을 동반하는 꽤 무거운 모듈이 됩니다.
4. Global Average Pooling (GAP)의 함정
ResNet이나 MobileNet의 마지막 단에 쓰이는 Global Average Pooling은 더 심각합니다. 7 * 7 혹은 그 이상의 전체 채널 사이즈를 평균 내야 합니다.
이는 이미지 전체가 끝날 때까지 누적값(Accumulator)을 들고 있어야 함을 의미합니다. 스트리밍 아키텍처에서 GAP는 데이터를 다 받을 때까지 다음 결과를 내보낼 수 없는 Latency 병목 구간이 됩니다.
5. 결론
하드웨어 아키텍처의 관점에서 연산의 단순함과 구현의 단순함은 별개입니다. Padding과 Pooling은 수학적으로는 유치원 수준이지만, 데이터를 실시간으로 흘려보내야 하는(Streaming) 하드웨어 입장에서는 데이터의 흐름을 방해하고 버퍼링을 강제하는 장애물입니다.
최근 등장하는 Transformer 계열이나 Stride=2 Convolution을 사용하는 모델들이 Pooling 레이어를 점차 없애는 추세인 것은, 정확도 측면뿐만 아니라 이러한 하드웨어 효율성(Hardware Efficiency)과도 무관하지 않습니다.
참고: Efficient Hardware Architecture for Moving Window Operations