달력

92026  이전 다음

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
반응형

지난 7월에 평일 저녁 4시간씩 20일(2026년 7월 2일 ~ 2026년 7월 30일), 총 80시간의 「AI 반도체 아키텍처 설계 및 성능 최적화 과정」을 들었습니다. Python에서 행렬곱을 실행하는 방법부터 메모리 계층, SIMD, Roofline, Systolic Array, NPU 구성, RTL과 설계 공간 탐색까지 순서대로 공부했습니다.

수도권ICT 이노베이션스퀘어 2026년 2기 AI반도체 아키텍처 설계 및 성능 최적화 과정

강의를 들으며 반복해서 확인한 것은 연산량만으로 성능을 설명할 수 없다는 점이었습니다. 같은 행렬곱도 어느 실행 계층에서 수행하는지, 데이터를 어느 메모리에 두는지, 무엇을 PE 안에 고정하고 무엇을 흘려보내는지에 따라 실제 비용이 달라집니다. 이 글은 지난 20일 간의 강의를 정리한 글입니다.

1. Python for loop는 왜 CPU를 제대로 사용하지 못할까

과정의 첫 실습은 Python의 삼중 반복문과 NumPy의 A @ B로 같은 행렬곱을 수행해 보는 것이었습니다. 두 방식은 같은 행렬곱을 계산하고 시간 복잡도도 같습니다. 행렬의 크기가 N × N이라면 이론적인 연산량은 곱셈과 덧셈을 합쳐 대략 다음과 같습니다.

2 × N³ FLOPs

그런데 실제 실행 시간은 수십 배에서 수백 배까지 벌어질 수 있습니다. 이유는 알고리즘이 아니라 실행 계층에 있습니다.

Python for loop는 원소 하나를 계산할 때마다 인터프리터 수준에서 바이트코드를 실행하고, 타입을 확인하고, Python 객체에 접근합니다. CPU가 행렬곱에 필요한 곱셈과 덧셈을 계속 수행하는 것이 아니라, Python이 매 원소의 실행을 해석하고 객체를 처리하기를 기다리는 시간이 길어집니다. 이 경우 병목은 CPU의 연산기가 아니라 Python이며, 계산 능력이 남아 있어도 CPU는 놀게 됩니다.

반면 NumPy의 A @ B는 행렬곱을 C/Fortran의 BLAS·LAPACK 계층으로 넘깁니다. 이 계층에서는 SIMD, 캐시 최적화와 멀티스레딩을 활용할 수 있습니다. Python 객체에 원소별로 접근하는 데 시간을 쓰는 대신, CPU가 연속된 숫자 데이터를 실제 계산에 효율적으로 사용합니다.

이 차이는 “NumPy가 더 적은 계산을 한다”는 뜻이 아닙니다. 같은 계산을 Python 인터프리터가 원소마다 지시하는지, 컴파일된 수치 연산 계층이 한꺼번에 수행하는지의 차이입니다. 그래서 NumPy 배열을 사용하더라도 Python for loop로 원소를 하나씩 꺼내 계산하면 여전히 느립니다. 데이터 구조만 NumPy로 바꾼 것이 아니라 연산 자체를 벡터화해야 실행 계층이 바뀝니다.

부동소수점 계산은 연산 순서에 따라 작은 오차가 생길 수 있습니다. 따라서 두 결과를 ==로 비교하기보다 다음처럼 허용 오차 안에서 같은지 확인합니다.

np.allclose(C_loop, C_np, atol=1e-3)

첫 실습의 결론은 단순합니다. Python으로 숫자 연산을 직접 반복하지 말고, 벡터화된 수치 연산 계층을 사용해야 합니다. NumPy는 편리한 배열 라이브러리인 동시에 Python에서 HPC 연산 계층으로 들어가는 인터페이스입니다.

2. 메모리 계층과 Row vs Column 접근은 무엇을 보여줄까

두 번째 장은 한 가지 실험이 아니라 두 축으로 구성되어 있었습니다. 메모리 위계에는 Register도 포함되지만, 첫 번째 실험은 Pointer Chasing으로 L1·L2·L3 Cache와 DRAM의 경계를 측정했습니다. 두 번째 실험은 같은 NumPy 행렬에서 행 방향 합과 열 방향 합을 비교했습니다.

Pointer Chasing으로 캐시 위계 측정하기

CPU의 저장 공간은 대체로 다음과 같은 계층을 이룹니다.

계층 일반적인 성격 접근 비용
Register 가장 작고 가장 가까움 가장 낮음
L1 Cache 작고 매우 빠름 낮음
L2 Cache L1보다 크고 느림 증가
L3 Cache 여러 코어가 공유할 수 있는 큰 캐시 더 증가
DRAM 크지만 CPU 밖에 있음 가장 높음

정확한 크기와 시간은 CPU마다 다르지만, 작업 집합이 어느 계층에 들어가는지에 따라 원소당 접근 시간이 계단처럼 달라지는 경향은 같습니다. 캐시는 보통 64Byte 정도의 캐시 라인 단위로 데이터를 가져옵니다. float32라면 한 캐시 라인에 16개 정도가 들어갑니다.

캐시가 효과를 내는 기본 원리는 지역성입니다.

  • 공간 지역성(spatial locality)은 방금 접근한 주소와 인접한 데이터를 곧 사용할 가능성이 높다는 뜻입니다.
  • 시간 지역성(temporal locality)은 최근 사용한 데이터를 곧 다시 사용할 가능성이 높다는 뜻입니다.

연속된 행 데이터를 읽는 것은 공간 지역성을 활용하고, PE register나 cache에 올린 weight를 반복해서 쓰는 것은 시간 지역성을 활용하는 예입니다.

순차 접근만 측정하면 CPU의 prefetcher가 다음 데이터를 미리 가져와 지연 시간을 가릴 수 있습니다. Pointer Chasing은 현재 읽은 값이 다음에 읽을 주소를 결정하도록 데이터 의존성을 만듭니다. 다음 주소를 미리 알기 어렵기 때문에 prefetcher가 개입하기 힘들고, 배열 크기를 1KB에서 수백 MB까지 늘리면 L1, L2, L3와 DRAM의 경계에서 원소당 평균 접근 시간이 증가하는 모습을 볼 수 있습니다.

이 실험의 목적은 특정 장비의 절대 시간을 외우는 것이 아닙니다. 작업 집합이 가까운 캐시를 벗어날 때 접근 비용이 어떻게 달라지는지 직접 확인하는 것입니다.

행 방향 합과 열 방향 합 비교하기

NumPy의 기본 2차원 배열은 C-contiguous, 즉 한 행이 메모리에 연속해서 저장되는 row-major 형태입니다. 강의의 정방행렬에서는 다음 두 연산의 결과 벡터 크기는 같고 원소별 값은 다르지만, 모두 원본 행렬의 모든 원소를 한 번씩 더합니다. 각 결과를 다시 합하면 같은 전체 합이 되며, 데이터를 읽고 누적하는 방향은 서로 다릅니다.

  • 행 방향 합: axis=1, contiguous, row-wise
  • 열 방향 합: axis=0, col-wise

행 방향 합은 한 행의 연속된 값을 따라갑니다. 열 방향 합은 논리적으로 같은 열의 원소를 읽기 때문에 행 사이를 건너갑니다. 이때 주소 간격을 설명하는 보조 용어가 stride입니다. 하지만 이 실험의 핵심은 stride라는 추상어 자체가 아니라, 행 방향 합과 열 방향 합의 실제 접근 방식이 다르다는 점입니다.

일반적인 C-contiguous 접근을 생각하면 행 방향 합이 캐시 라인을 연속해서 사용하기 쉽고, 열 방향 합은 불리할 것으로 예상할 수 있습니다. 다만 NumPy의 sum()은 단순한 Python 열 순회가 아닙니다. 일부 NumPy 빌드와 reduction 구현은 axis=0도 contiguous storage를 순차로 읽고, 작은 열 결과 버퍼를 L1에 유지하는 streaming reduction으로 처리할 수 있습니다. Apple Accelerate를 사용하는 환경처럼 이런 최적화가 적용되면 열 방향 합이 오히려 빠르거나 두 방식의 차이가 작을 수 있습니다.

따라서 이 실험에서는 결과가 어느 방향으로 나왔는지만 보는 것이 아니라, 플랫폼과 라이브러리 구현에 따라 예상이 뒤집힐 수 있다는 점도 함께 봐야 합니다. 캐시 위계 자체는 Pointer Chasing으로 분리해 측정하고, Row vs Column 실험에서는 NumPy가 실제 접근을 어떻게 최적화하는지를 해석합니다.

Python list와 NumPy 배열의 차이도 같은 맥락에 있습니다. Python list는 float 값 자체가 아니라 Python 객체를 가리키는 포인터의 배열입니다. 원소에 접근할 때 포인터를 따라가고 객체를 처리해야 합니다. NumPy 배열은 같은 dtype의 raw 데이터를 연속해서 배치하므로 캐시와 SIMD가 사용하기 좋은 형태입니다.

결국 두 실험이 답하는 질문은 서로 다릅니다.

실험 측정 대상 핵심
Pointer Chasing 캐시 위계의 순수한 latency 차이 데이터 의존성으로 prefetcher를 무력화
Row vs Column NumPy의 접근 패턴과 구현 차이 row-wise와 col-wise, 그리고 플랫폼 의존성

3. SIMD는 왜 벡터화된 연산에서만 힘을 쓸까

SIMD는 Single Instruction Multiple Data의 약자로, 하나의 명령으로 여러 데이터를 동시에 처리하는 방식입니다. scalar add가 한 번에 float 하나를 더한다면, 8개의 float32 lane을 가진 AVX2 명령은 한 명령으로 8개의 덧셈을 수행할 수 있습니다.

ISA 예시 레지스터 폭 float32 lane
SSE 128bit 4
AVX2 256bit 8
AVX-512 512bit 16
ARM NEON 128bit 4

FMA(Fused Multiply-Add)는 d = a × b + c를 한 명령으로 수행합니다. 곱셈과 덧셈을 각각 한 번의 연산으로 세기 때문에 lane 하나당 2 FLOP으로 계산합니다. 단일 코어의 이론적인 상한은 강의에서 다음처럼 계산했습니다.

Peak GFLOPS = Clock(GHz) × SIMD lane × FMA factor

예를 들어 3GHz, 8 lane, FMA를 가정하면 코어당 이론값은 48GFLOPS입니다. 이 값은 모든 lane에 매 cycle 유효한 데이터를 공급할 수 있다는 상한입니다.

Python loop가 SIMD를 제대로 사용하지 못하는 이유는 1절과 연결됩니다. 인터프리터가 원소를 하나씩 dispatch하고, 각 원소는 PyObject의 참조와 타입 확인을 거칩니다. 컴파일러가 연속된 수치 연산을 하나의 SIMD 명령으로 묶을 자리가 없습니다.

강의에서는 같은 벡터 덧셈을 세 가지로 비교했습니다.

  1. Python list와 Python loop
  2. NumPy 배열과 Python loop
  3. NumPy 배열의 벡터화 연산 c = a + b

두 번째 방식은 데이터가 NumPy 배열이어도 연산을 Python이 원소마다 지시하므로 여전히 느립니다. 세 번째 방식에서야 연산 전체가 컴파일된 계층으로 넘어가 SIMD와 벡터화를 활용합니다. 중요한 것은 “NumPy 배열을 썼는가”가 아니라 “Python의 원소별 dispatch를 벗어났는가”입니다.

CPU와 GPU의 차이도 lane 관점에서 볼 수 있습니다. CPU는 적은 수의 강한 코어와 4~16개 정도의 SIMD lane으로 latency와 분기 처리에 강합니다. GPU는 warp와 SM을 통해 훨씬 많은 lane을 동시에 사용해 throughput을 높입니다.

4. Roofline은 Compute와 Memory 중 무엇이 병목인지 어떻게 판단할까

연산기가 아무리 빨라도 데이터를 가져오는 시간이 더 오래 걸리면 전체 실행 시간은 줄지 않습니다. Roofline은 workload의 실행 시간이 계산 능력과 메모리 전송 능력 중 느린 쪽에 의해 결정된다고 보는 모델입니다.

T_compute = total_flops / peak_flops_per_sec
T_memory  = bytes_moved / peak_bandwidth_per_sec
T_actual  ≈ max(T_compute, T_memory)

계산 시간이 더 크면 compute-bound, 메모리 시간이 더 크면 memory-bound입니다. 이때 중요한 값이 Arithmetic Intensity(AI)입니다.

AI = FLOPs / bytes_moved

AI는 데이터 1Byte를 가져온 뒤 얼마나 많은 연산을 수행하는지를 나타냅니다. AI가 낮으면 데이터를 계속 가져와야 하므로 memory-bound가 되기 쉽고, AI가 높으면 같은 데이터를 여러 번 재사용하며 계산하므로 compute-bound가 되기 쉽습니다.

하드웨어마다 계산 peak와 메모리 대역폭이 다르기 때문에 경계가 되는 AI도 다릅니다.

AI_critical = peak_flops / peak_bandwidth

workload의 AI가 이 값보다 낮으면 메모리 쪽, 높으면 계산 쪽의 제한을 받습니다. 성능의 상한은 다음처럼 표현할 수 있습니다.

Performance ≤ min(Peak Compute, AI × Bandwidth)

N×N float32 MatMul을 A와 B를 한 번 읽고 C를 한 번 쓰는 단순 모델로 보면 FLOPs는 2N³, 이동량은 12N² Byte입니다.

AI = 2N³ / 12N²
   = N / 6

N = 4096일 때
AI ≈ 683 FLOPs/Byte

이 계산은 A와 B를 한 번 읽고 C를 한 번 쓰는 single-pass traffic을 가정한 Roofline 표의 근사입니다. 강의 simulator에는 이와 별도로, 자신이 정의한 naive traffic에 cache 재사용 효과를 반영하는 heuristic도 등장합니다.

bytes_actual = bytes_naive / reuse_factor

두 식을 한 계산에 연달아 적용하는 것은 아닙니다. 어떤 값을 bytes_moved로 둘지에 대한 서로 다른 근사이므로, 분석할 때 사용한 traffic 가정을 함께 밝혀야 합니다.

행렬곱은 같은 입력과 가중치를 여러 출력 계산에 재사용할 수 있고, blocking과 SIMD에 적합하기 때문에 AI가 높은 대표적인 연산입니다. 반면 배열 덧셈처럼 각 데이터를 읽어 적은 계산만 수행하는 작업은 memory-bound가 되기 쉽습니다.

이 모델은 이후에도 계속 사용됩니다. CPU, GPU와 NPU는 전혀 다른 공식으로 설명되는 기계가 아니라, peak와 bandwidth가 서로 다른 하드웨어를 같은 Roofline 위에 놓고 비교할 수 있습니다. 다만 실제 성능을 높이려면 peak만 보는 것이 아니라 cache reuse와 실제 bytes moved를 함께 봐야 합니다.

5. CPU, GPU와 NPU는 무엇을 우선하도록 설계되었을까

CPU, GPU와 NPU는 모두 계산을 수행하지만, 어떤 workload를 우선하느냐가 다릅니다.

구분 CPU GPU NPU
설계 우선순위 latency와 범용성 대규모 병렬 throughput AI workload 특화
병렬성 적은 수의 강한 코어와 SIMD warp·SM 기반의 많은 lane Systolic Array 등 고정된 연산 배열
정밀도 FP64·FP32 중심 FP32·FP16·FP8 등 INT8·INT4·FP16·BF16 등
메모리 자동 캐시 계층 캐시와 큰 register 온칩 SRAM scratchpad를 명시적으로 관리
제어 흐름 자유로움 divergence 비용 매우 제한적

강의에서는 NPU가 효율적인 이유를 네 가지로 정리했습니다.

첫째, 낮은 정밀도를 사용합니다. INT8은 FP32보다 원소가 작아 같은 면적과 대역폭에서 더 많은 데이터를 처리할 수 있습니다. 다만 입력 정밀도를 낮춰도 누산 결과는 더 넓은 비트 폭이 필요하며, 이 문제는 이후에 MAC 설계에서 다시 다룹니다.

둘째, 연산 패턴을 MatMul과 Convolution 같은 AI 연산에 맞춥니다. 범용 제어에 쓰는 면적을 줄이고, 반복되는 곱셈-누적에 더 많은 자원을 배치합니다.

셋째, 데이터 이동을 통제합니다. GPU의 캐시는 하드웨어가 자동으로 관리하지만, NPU의 scratchpad는 컴파일러나 드라이버가 어떤 데이터를 언제 올려둘지 명시적으로 결정할 수 있습니다.

넷째, Weight Stationary 같은 고정 dataflow로 데이터를 가능한 오래 재사용합니다.

그래서 NPU는 단순히 lane 수가 더 많은 GPU가 아닙니다. 좁은 AI workload를 선택하고, 정밀도·메모리·dataflow를 그 workload에 맞춰 깊게 특화한 칩입니다.

6. Stationary Dataflow는 반복문의 무엇을 PE에 고정할까

Stationary Dataflow에 대한 설명은 행렬곱의 수학식에서 시작했습니다.

C[i,j] = Σ A[i,k] × B[k,j]

결과는 같아도 i, j, k 반복문의 순서를 바꾸면 가장 안쪽 반복문에서 변하지 않는 값이 달라집니다. 강의에서는 이 값을 PE에 고정해 재사용하는 관점으로 Stationary Dataflow를 설명했습니다.

핵심 아이디어는 “데이터 이동은 비싸므로, 고정할 수 있는 데이터는 PE 가까이에 고정한다”입니다. 가장 자주 사용하는 값을 PE register에 두면 매번 SRAM이나 DRAM에서 다시 가져오지 않아도 됩니다.

세 개의 반복문은 6가지 순서로 배치할 수 있지만, 가장 안쪽 반복문에서 무엇이 변하지 않는지를 보면 세 종류로 묶입니다.

Loop ordering 가장 안쪽 변수 변하지 않는 값 분류
ijk, jik k C[i,j] Output Stationary
ikj, kij j A[i,k] Input Stationary
jki, kji i B[k,j] Weight Stationary

Output Stationary

for i:
    for j:
        for k:
            C[i,j] += A[i,k] × B[k,j]

k가 변하는 동안 C[i,j]는 같은 위치에 남습니다. 하나의 출력 원소를 끝까지 누적하므로 partial sum을 PE register에 유지하는 방식입니다.

Input Stationary

for i:
    for k:
        for j:
            C[i,j] += A[i,k] × B[k,j]

j가 변하는 동안 A[i,k]가 고정됩니다. 하나의 입력을 여러 weight와 곱해 여러 출력에 사용합니다.

Weight Stationary

for j:
    for k:
        for i:
            C[i,j] += A[i,k] × B[k,j]

i가 변하는 동안 B[k,j]가 고정됩니다. 하나의 weight를 여러 입력 행에 반복 사용합니다.

공장에 비유하면 OS는 조립대에 제품을 놓고 완성하는 방식이고, IS는 재료 하나를 들고 여러 도구를 사용하는 방식이며, WS는 도구를 고정하고 여러 재료를 공급하는 방식입니다. 같은 행렬곱을 수행하지만 무엇을 고정하고 무엇을 움직이느냐가 다릅니다.

7. Weight-Stationary Systolic Array는 한 cycle에 무엇을 할까

강의에서는 Google TPU를 대표적인 Systolic Array 사례로 다뤘습니다. 범용 연산을 모두 잘하기보다 MatMul을 중심으로 다이 면적과 데이터 경로를 구성하는 선택입니다. Systolic Array의 기본 단위인 PE(Processing Element)는 MAC을 수행합니다.

weight register : PE에 고정된 B[k,n]
multiplier      : activation × weight
adder           : 위에서 내려온 partial sum + 곱셈 결과

과정에서 사용한 Weight-Stationary 배열에서는 weight를 PE에 미리 적재하고, activation은 왼쪽에서 오른쪽으로 흐르며, partial sum은 위에서 아래로 흐릅니다.

TPU가 WS를 선택한 이유도 추론 workload와 연결됩니다. 학습이 끝난 weight는 추론 동안 고정되고, 같은 모델을 수많은 입력에 반복 적용합니다. 따라서 weight를 한 번 PE에 올린 뒤 가능한 오래 재사용하면 weight의 DRAM 접근을 크게 줄일 수 있습니다.

activation → [ PE ] → [ PE ] → [ PE ] → [ PE ]
               ↓        ↓        ↓        ↓
            partial   partial   partial   partial
              sum       sum       sum       sum

4×4 행렬곱이라면 B의 16개 값을 4×4 PE의 weight register에 먼저 넣습니다. 이후 A의 원소를 동시에 넣는 것이 아니라 대각선 모양의 wavefront가 만들어지도록 지연시켜 주입합니다.

A[m,k] enters PE[k,0] at cycle m+k

step(left)는 배열을 정확히 1 cycle 진행하는 동작입니다.

  1. 각 PE가 현재 activation과 고정된 weight를 곱합니다.
  2. 위에서 받은 partial sum에 곱셈 결과를 더합니다.
  3. activation을 오른쪽 PE로 전달합니다.
  4. 새 partial sum을 아래쪽 PE로 전달합니다.
  5. 맨 아래 행에서 나온 값을 bottom으로 수집합니다.

초기에는 유효한 출력이 없습니다. 4×4 예제의 cycle 0~3은 activation과 partial sum이 배열을 채우는 pipeline fill 구간입니다. cycle 4에서 첫 번째 유효한 값인 C[0,0]이 아래쪽 경계에 도착합니다.

새 입력이 모두 들어간 뒤에도 계산이 바로 끝나지는 않습니다. 배열 내부에 남은 activation과 partial sum이 아래쪽으로 빠져나와야 합니다. 이 구간이 pipeline drain입니다.

강의의 4×4 simulator에서 첫 주입부터 마지막 출력까지의 end-to-end cycle 수는 다음과 같습니다.

M + K + N - 1 = 11 cycles
(first injection → last output)

4×4에서는 유효 출력이 cycle 4부터 10까지 나오고, 첫 주입인 cycle 0부터 마지막 출력인 cycle 10까지가 11 cycle입니다. 구현 코드는 마지막 flush까지 포함해 cycle 0~11, 총 12번 step()을 실행했습니다. 16×16 예제에서도 48 cycle 뒤 NumPy의 A @ B와 결과가 일치했습니다. 중요한 것은 특정 숫자보다 전체 시간이 대략 M + K + N에 비례하고, 처음과 끝에 fill과 drain이 존재한다는 점입니다.

Systolic Array가 빠른 이유는 PE의 개수만이 아닙니다.

  • Weight는 PE에 한 번 올린 뒤 여러 입력 행에 재사용합니다.
  • Activation은 여러 열을 지나며 서로 다른 weight와 곱해집니다.
  • Partial sum은 배열 안에서 아래로 전달되므로 계산 도중 외부 메모리를 왕복하지 않습니다.
  • 짧고 규칙적인 이웃 연결을 반복해 큰 배열로 확장할 수 있습니다.

4×4에서는 각 weight와 input을 네 번씩 재사용합니다. TPU v1의 256×256 배열이라면 65,536개의 PE가 같은 cycle에 MAC을 수행할 수 있습니다. 다만 배열이 채워지기 전과 비워지는 동안에는 모든 PE가 일하지 않으므로 workload 크기와 배열 모양이 중요합니다.

다음은 강의와 실습에서 사용된 Systolic Array의 Python 구현 코드입니다.

"""Reference for Week 2-2: cycle-by-cycle weight-stationary systolic array simulator."""
import numpy as np

np.seterr(over="ignore", divide="ignore", invalid="ignore")


class SystolicArray:
    """Weight-stationary K×N PE array.

    State per PE:
      - weight (loaded once, stationary)
      - pe_input  : input value currently held (will shift right next cycle)
      - pe_ps_in  : partial-sum coming in from above (becomes ps_out + input·weight after compute)

    Each step():
      1. ps_out[k,n] = pe_ps_in[k,n] + pe_input[k,n] * weight[k,n]
      2. Bottom row's ps_out is emitted as output.
      3. pe_input shifts right by one column; leftmost column gets new external feed.
      4. pe_ps_in shifts down by one row; top row remains 0.
    """

    def __init__(self, rows: int, cols: int) -> None:
        self.rows = rows
        self.cols = cols
        self.weights = np.zeros((rows, cols), dtype=np.float32)
        self.pe_input = np.zeros((rows, cols), dtype=np.float32)
        self.pe_ps_in = np.zeros((rows, cols), dtype=np.float32)
        self.cycle = 0

    def reset(self) -> None:
        self.weights[:] = 0
        self.pe_input[:] = 0
        self.pe_ps_in[:] = 0
        self.cycle = 0

    def load_weights(self, B: np.ndarray) -> None:
        assert B.shape == (self.rows, self.cols)
        self.weights[:] = B

    def step(self, left_inputs: np.ndarray) -> np.ndarray:
        ps_out = self.pe_ps_in + self.pe_input * self.weights
        bottom = ps_out[-1].copy()

        new_pe_input = np.zeros_like(self.pe_input)
        new_pe_input[:, 1:] = self.pe_input[:, :-1]
        new_pe_input[:, 0] = left_inputs

        new_pe_ps_in = np.zeros_like(self.pe_ps_in)
        new_pe_ps_in[1:] = ps_out[:-1]

        self.pe_input = new_pe_input
        self.pe_ps_in = new_pe_ps_in
        self.cycle += 1
        return bottom

    def run(self, A: np.ndarray, B: np.ndarray, trace: bool = False) -> tuple[np.ndarray, int]:
        M, K = A.shape
        K2, N = B.shape
        assert K == K2 == self.rows
        assert N == self.cols

        self.reset()
        self.load_weights(B)

        outputs_per_cycle = []
        max_cycle = M + K + N
        for t in range(max_cycle):
            left = np.zeros(K, dtype=np.float32)
            for k in range(K):
                m = t - k
                if 0 <= m < M:
                    left[k] = A[m, k]
            bottom = self.step(left)
            outputs_per_cycle.append(bottom.copy())
            if trace:
                print(f"  cycle {t}: left={left.tolist()}  bottom={bottom.tolist()}")
                with np.printoptions(precision=3, suppress=True):
                    print(f"    pe_ps_in:\n{self.pe_ps_in}")

        C = np.zeros((M, N), dtype=np.float32)
        for m in range(M):
            for n in range(N):
                t_out = m + K + n
                if t_out < len(outputs_per_cycle):
                    C[m, n] = outputs_per_cycle[t_out][n]
        return C, self.cycle


def main() -> None:
    np.random.seed(0)
    M, K, N = 4, 4, 4
    A = np.random.rand(M, K).astype(np.float32)
    B = np.random.rand(K, N).astype(np.float32)
    C_ref = A @ B

    print(f"4×4 weight-stationary systolic array doing {M}×{K}×{N} MatMul\n")

    sa = SystolicArray(K, N)
    print("Input schedule (A[m,k] enters PE[k,0] at cycle m+k):")
    for k in range(K):
        for m in range(M):
            print(f"  A[{m},{k}] = {A[m,k]:.3f}  enters PE[{k},0] at cycle {m+k}")

    print("\n--- Running with trace ---")
    C, total_cycles = sa.run(A, B, trace=True)

    print(f"\nTotal cycles: {total_cycles}")
    print(f"Theoretical = M + K + N - 1 = {M + K + N - 1} useful cycles")
    print(f"\nC computed by systolic:")
    with np.printoptions(precision=3, suppress=True):
        print(C)
    print(f"\nC by NumPy (A @ B):")
    with np.printoptions(precision=3, suppress=True):
        print(C_ref)
    print(f"\nallclose (atol=1e-3): {np.allclose(C, C_ref, atol=1e-3)}")

    print("\n--- Larger test (no trace): 16×16×16 ---")
    M = K = N = 16
    A = np.random.rand(M, K).astype(np.float32)
    B = np.random.rand(K, N).astype(np.float32)
    sa16 = SystolicArray(K, N)
    C, cycles = sa16.run(A, B)
    print(f"  cycles: {cycles}, allclose: {np.allclose(C, A @ B, atol=1e-3)}")


if __name__ == "__main__":
    main()

8. WS, OS와 IS 중 어떤 방식이 가장 효율적일까

Eyeriss의 분류에서는 WS, OS, IS뿐 아니라 convolution의 여러 재사용을 함께 고려하는 Row Stationary(RS)도 다룹니다. 강의의 비교 실습은 같은 MatMul을 WS, OS와 IS로 각각 계산하고, 결과가 아니라 메모리 접근과 데이터 이동이 어떻게 달라지는지 보는 데 초점을 맞췄습니다.

비교한 항목은 DRAM 읽기량, DRAM 쓰기량, PE 사이의 데이터 이동량과 전체 실행 시간이었습니다. WS는 weight DRAM read, OS는 output의 중간 DRAM write, IS는 input DRAM read를 줄이는 방향입니다.

simulate(A, B, dataflow)
dataflow ∈ {WS, OS, IS}
결과 C는 모두 A @ B와 동일

연산 결과는 같지만 비용은 다릅니다. 강의에서 사용한 대략적인 접근 에너지의 크기는 다음과 같습니다.

데이터 위치 대략적인 에너지/Byte
DRAM 약 100pJ
On-chip SRAM 약 5pJ
PE Register 약 1pJ

정확한 수치는 공정과 구현에 따라 달라지지만, DRAM에서 가져오는 비용이 PE register 접근보다 훨씬 크다는 방향은 분명합니다. 그래서 “어떤 데이터를 어디에 오래 머물게 할 것인가”가 에너지와 성능을 함께 좌우합니다.

Weight Stationary

Weight를 PE에 고정해 weight read를 줄입니다. 같은 모델의 weight를 많은 입력에 반복 사용하는 추론이나 큰 batch의 Fully Connected Layer에 유리합니다.

Output Stationary

Output partial sum을 PE 안에서 끝까지 누적해 중간 결과의 DRAM read/write를 줄입니다. 일반적인 GEMM처럼 출력 누적 비용이 큰 경우에 유리합니다.

Input Stationary

Input을 PE에 고정하고 여러 weight에 사용해 input read를 줄입니다. M=1, K=4096, N=4096처럼 하나의 입력 벡터로 많은 출력을 계산하는 GEMV 성격의 batch-1 추론에서 유리할 수 있습니다.

어느 방식이 항상 가장 좋다고 말할 수는 없습니다. 큰 batch에서 같은 weight를 반복하면 WS가 유리하고, 긴 partial sum을 외부에 쓰지 않으려면 OS가 유리하며, 하나의 입력을 많은 출력에 쓰면 IS가 유리합니다. 핵심 질문은 곱셈을 어떻게 바꿀 것인가가 아니라, 같은 곱셈을 위해 데이터를 어떻게 움직일 것인가입니다.

Compute is cheap. Data movement is expensive.

9. 가장 빠른 하드웨어는 왜 workload마다 달라질까

마지막으로 행렬 가속 명령이 있는 CPU(M+AMX), RTX 4090, H100, TPU v4와 Apple Neural Engine을 같은 Roofline 관점에서 비교했습니다. 비교한 workload는 크기와 모양이 서로 달랐습니다.

Workload 행렬 모양 M×K×N 성격
Tiny 32×32×32 매우 작은 연산
LLM single 1×4096×4096 Batch-1, GEMV 성격
LLM batched 256×4096×4096 큰 batch의 GEMM
Large 4096³ 초대형 GEMM
Attention 1024×128×1024 K가 작은 행렬곱

Tiny workload에서는 계산량보다 실행 준비와 호출 오버헤드가 더 크게 보일 수 있습니다. 큰 GPU나 TPU의 연산 능력을 활용하기 전에 작업이 끝나므로 CPU가 더 빠를 수 있습니다.

Batch-1의 LLM single은 M=1인 GEMV에 가깝습니다. 병렬성이 제한되고 큰 weight를 한 입력에 사용하므로 메모리 특성이 중요합니다. GPU와 TPU의 넓은 병렬 자원이 항상 충분히 활용되는 것은 아닙니다.

LLM batched는 같은 weight를 많은 입력에 재사용하는 큰 GEMM입니다. 연산량과 재사용률이 커서 H100이나 TPU 같은 대규모 병렬 장치가 강점을 보이기 쉽습니다.

Large GEMM은 AI가 높고 계산량이 매우 커 compute-bound에 가까워집니다. 이 경우 하드웨어의 peak compute가 결과에 크게 영향을 줍니다.

Attention 예시는 K가 128로 작습니다. 행렬곱의 형태는 유지되지만 reduction 길이가 짧아 재사용과 병렬성이 제한되고 메모리의 영향이 커질 수 있습니다.

이 비교의 결론은 특정 장치의 순위를 외우는 것이 아닙니다. “어떤 하드웨어가 최고인가?”라는 질문에 답하려면 먼저 “어떤 workload인가?”를 물어야 합니다. 문제의 크기, batch, K의 길이, 재사용률, 오버헤드와 메모리 대역폭이 달라지면 유리한 하드웨어도 달라집니다.

10. NPU 아키텍처는 어떤 순서로 정의해야 할까

지금까지 살펴본 개념을 실제 NPU 설계 변수로 바꿨습니다. 먼저 PE 수부터 정하는 것이 아니라, 어떤 문제를 풀 것인지부터 시작합니다.

Target Workload
→ Defining Feature
→ DType Selection
→ PE Array Shape
→ Dataflow Selection
→ Clock / SRAM / DRAM Bandwidth
→ NPUConfig
→ Peak TOPS / Roofline Analysis

Target Workload는 NPU가 주로 처리할 작업입니다. LLM training, LLM inference, edge AI와 mobile AI는 필요한 정밀도, 메모리 용량, 전력과 처리량이 서로 다릅니다. 따라서 workload를 정하지 않고 “좋은 NPU”를 설계할 수는 없습니다.

Defining Feature는 설계 철학을 한 문장으로 압축한 것입니다. 강의에서는 다음과 같은 예시를 사용했습니다.

INT8 + 16MB SRAM, 16×16 Systolic Array for Batched Inference

이 한 문장에는 목표 workload, 데이터 타입, 온칩 메모리와 계산 배열이 함께 들어 있습니다. “적당한 정밀도로 batched inference를 빠르게 처리한다”는 설계 방향이 먼저 정해지면 나머지 변수도 같은 방향으로 맞출 수 있습니다.

데이터 타입은 정확도만 바꾸는 변수가 아닙니다.

DType Byte 주된 용도와 영향
INT8 1 추론 최적화, 작은 메모리 사용량과 높은 연산 효율
INT16 2 중간 정밀도의 정수 연산
FP16 2 학습과 추론
BF16 2 대규모 AI 학습과 추론
FP32 4 높은 정밀도, 더 큰 메모리·연산 비용

같은 SRAM에도 INT8 데이터는 FP32보다 더 많이 들어갑니다. 같은 메모리 대역폭으로 더 많은 operand를 공급할 수도 있습니다. 반대로 데이터 타입을 낮추면 정확도와 overflow를 함께 검토해야 합니다.

PE Array Shape는 MAC을 수행하는 Processing Element를 몇 행, 몇 열로 배치할지 정합니다. 16×16 배열이면 총 PE 수는 256개입니다.

N_PE = PE_rows × PE_cols
     = 16 × 16
     = 256

각 PE가 매 cycle 하나의 MAC을 수행하고, 하나의 MAC을 multiply와 add 두 operation으로 세면 peak 성능은 다음과 같습니다.

Peak Ops/s = N_PE × 2 × Clock

256 PEs × 2 × 1GHz
= 512 GOPS
= 0.512 TOPS

이 값은 모든 PE가 매 cycle 유효한 MAC을 수행할 때의 이론적 상한입니다. 실제 성능에는 배열의 fill과 drain, workload 모양, PE 활용률과 메모리 공급이 반영됩니다.

Dataflow는 PE에 무엇을 고정할지 정합니다. 앞에서 살펴본 WS, OS와 IS가 여기에서 실제 설계 변수가 됩니다. Clock은 PE가 움직이는 속도, SRAM은 input·weight·partial sum을 온칩에 유지할 수 있는 크기, DRAM bandwidth는 외부에서 데이터를 공급할 수 있는 속도를 나타냅니다.

이 결정을 NPUConfig 하나에 모으면 설계가 비교 가능한 형태가 됩니다.

NPUConfig
├── target_workload
├── dtype
├── pe_array_rows / pe_array_cols
├── dataflow
├── clock_ghz
├── sram_kb
└── dram_bw_gbps

블록 다이어그램은 단순히 상자를 그리는 작업이 아닙니다. workload를 어떻게 dtype, PE array, dataflow와 memory system으로 구체화했는지 보여주는 설계 지도입니다. 결국 첫 단계는 “워크로드 정의 → 아키텍처 결정 → 성능과 비용 분석”을 하나의 흐름으로 연결하는 작업이었습니다.

11. MAC 유닛은 왜 작은 곱셈기 하나로 끝나지 않을까

NPU의 가장 작은 계산 단위는 MAC(Multiply-Accumulate)입니다.

acc ← acc + (a × b)

벡터 내적, 행렬곱, convolution, attention과 Transformer의 계산도 결국 이 연산의 반복으로 표현할 수 있습니다. 하지만 수식 한 줄을 실제 회로로 바꾸려면 정밀도, clock, reset과 검증 방법까지 정해야 합니다.

Accumulator는 왜 입력보다 넓어야 할까

INT8의 범위는 -128부터 127까지입니다. 그런데 127 × 127 = 16,129이므로 곱셈 결과부터 이미 INT8 범위를 벗어납니다. 여러 곱을 계속 더하면 필요한 범위는 더 커집니다.

강의에서는 길이 1,000인 두 벡터의 원소가 모두 1인 dot product를 비교했습니다.

Expected                         = 1000
INT8 × INT8, INT8 accumulator   = overflow
INT8 × INT8, INT32 accumulator  = 1000

그래서 추론에서 흔히 INT8 × INT8 → INT32 accumulator를 사용합니다. 부동소수점에서도 같은 이유로 FP16 × FP16 → FP32 accumulator를 사용할 수 있습니다. 입력과 weight의 저장 비용은 낮추면서, partial sum은 더 넓은 타입에 누적해 정확도와 범위를 확보하는 방식입니다.

Python 모델과 Verilog RTL은 무엇이 다를까

같은 MAC을 여러 추상화 수준에서 볼 수 있습니다.

수준 구현 목적
알고리즘 Python class 계산 결과와 동작 검증
아키텍처 Python performance model 메모리·cycle·energy 탐색
RTL Verilog module 실제 회로 구조 표현
검증 cocotb Python reference와 RTL 비교
파형 VCD와 waveform viewer 시간에 따른 신호 변화 확인

Python 모델은 “어떻게 계산해야 하는가”를 표현합니다. step(a, b)를 한 번 호출할 때마다 accumulator를 갱신하면 빠르게 overflow와 dtype 조합을 실험할 수 있습니다. 반면 Verilog는 그 계산을 수행할 실제 register, multiplier, adder와 연결 구조를 표현합니다.

always @(posedge clk) begin
    if (rst)
        acc <= 32'sd0;
    else
        acc <= acc + (in_data * weight);
end

이 기본 MAC은 다음 의미를 가집니다.

  • posedge clk마다 상태를 갱신합니다.
  • rst가 켜지면 accumulator를 0으로 초기화합니다.
  • in_dataweight는 signed INT8입니다.
  • 곱은 signed 16bit 범위가 필요하고, 결과는 signed INT32 acc에 누적합니다.
  • <=는 sequential logic에서 사용하는 non-blocking assignment입니다. 같은 clock edge의 갱신을 코드의 실행 순서가 아니라 동시에 일어나는 register 변화로 해석합니다.

이 기본형의 reset은 always @(posedge clk) 안에서만 검사하므로 clock edge에서 동작하는 synchronous reset입니다. 이에 대한 개선형은 sensitivity list에 negedge rst_n을 추가해 clock과 무관하게 reset되는 asynchronous active-low reset으로 바뀝니다.

Python은 CPU가 명령을 순차적으로 실행하지만, Verilog의 module은 회로 구조입니다. assign x = a + b는 “이 줄을 지금 실행하라”가 아니라 “a와 b를 더하는 조합 회로가 항상 연결되어 있다”는 뜻입니다.

왜 Python 모델을 먼저 만들고 cocotb로 다시 비교할까

RTL부터 작성하면 SRAM 크기, DRAM bandwidth, dtype와 dataflow 조합을 넓게 탐색하기 어렵습니다. 그래서 실제 개발 흐름도 기능 모델을 먼저 만들고 점차 구체화합니다.

Python model
→ algorithm verification
→ performance / energy exploration
→ Verilog RTL
→ cocotb cross-check
→ synthesis and PPA analysis
→ silicon

cocotb는 Python에서 DUT(Device Under Test)를 제어합니다. 입력과 weight를 넣고 rising edge를 진행한 뒤, RTL accumulator와 Python MACUnit의 결과를 비교합니다.

Python golden model ─┐
                     ├─ 같은 입력 ─→ 결과 비교 ─→ PASS / FAIL
Verilog MAC (DUT) ───┘
                              └────→ dump.vcd

두 결과가 같다면 “회로가 알고리즘 명세와 같은 계산을 수행한다”는 근거를 얻습니다. VCD waveform에서는 clk, rst, input, weight와 accumulator가 cycle마다 어떻게 변하는지도 확인할 수 있습니다.

12. 개별 부품은 어떻게 하나의 NPU 시뮬레이터가 될까

NPU 시뮬레이터 설계의 마지막 단계는 지금까지 만든 설정과 부품을 하나의 실행 가능한 시스템으로 통합하는 작업이었습니다.

구성 요소 역할
NPUConfig clock, PE 배열, dtype, SRAM과 DRAM bandwidth를 담는 설계도
Memory SRAM과 DRAM의 크기·대역폭·지연·에너지 모델
MACUnit 곱셈-누적의 기능과 연산 에너지 모델
SystolicArray PE 배열의 계산 구조와 dataflow
NPU.run() 타일 이동, 계산, 통계 수집을 순서대로 조율하는 실행부
RunResult 결과, cycle, energy, traffic과 utilization을 묶는 출력
NPU
├── PE Array
├── SRAM
├── DRAM
└── Stats
    ├── cycles
    ├── energy
    ├── DRAM read / write bytes
    └── PE utilization

NPU.run()은 스스로 MAC을 수행하지 않습니다. 입력 행렬의 차원을 확인하고, tile을 나누고, weight와 input을 옮기고, PE Array를 실행한 뒤 결과와 지표를 반환하는 orchestrator입니다.

실행 순서는 다음과 같습니다.

  1. A(M,K)B(K,N)의 차원이 맞는지 확인합니다.
  2. M, N과 K를 PE 배열 크기에 맞춰 tile로 나눕니다.
  3. Weight-Stationary 순서로 weight tile을 적재하고 input tile을 stream합니다.
  4. K 방향 partial sum을 모두 누적한 뒤 output tile을 기록합니다.
  5. cycle, DRAM traffic, energy와 PE utilization을 계산합니다.
  6. 실제 행렬 결과와 함께 RunResult를 반환합니다.

이 시뮬레이터는 얼마나 정확할까

강의의 통합 모델은 결과 행렬은 실제로 계산하고, 성능 지표는 수식으로 추정하는 Analytical NPU Simulator (Functional + Analytical Performance Model)입니다. Tile loop 수준에서 구조와 재사용을 반영하지만, 모든 PE와 신호를 매 cycle 실행하는 cycle-accurate simulator나 RTL simulation은 아닙니다.

Functional simulation:
    C = A @ B

Analytical modeling:
    cycles       = f(compute_cycles, dram_cycles)
    energy       = f(MAC count, DRAM bytes, SRAM bytes)
    utilization  = actual MACs / available MAC slots

빠르게 많은 설계를 비교할 수 있으면서도 Roofline 하나보다 구체적으로 tile, SRAM fit과 PE 활용률을 볼 수 있다는 것이 이 수준의 장점입니다.

Tile, cycle과 utilization은 어떻게 계산할까

먼저 필요한 tile 개수를 올림으로 구합니다.

nt_m = ceil(M / PE_R)
nt_n = ceil(N / PE_C)
nt_k = ceil(K / PE_R)

강의 모델의 compute cycle은 각 tile의 K 방향 streaming과 배열의 fill/drain을 합쳐 다음처럼 계산합니다.

cycles_per_tile = PE_R

compute_cycles
= nt_m × nt_n × nt_k × PE_R
  + (PE_R + PE_C - 1)

DRAM에서 옮겨야 할 전체 Byte를 clock당 공급 가능한 Byte로 나누면 DRAM cycle을 얻습니다.

bytes_per_cycle = DRAM_BW_GB/s / Clock_GHz
dram_cycles     = ceil(dram_total_bytes / bytes_per_cycle)

total_cycles
= max(compute_cycles, dram_cycles)
  + overhead_cycles

max()를 사용하는 이유는 compute와 DRAM transfer가 겹친다고 단순화했기 때문입니다. 계산이 더 오래 걸리면 compute-bound, 데이터 공급이 더 오래 걸리면 memory-bound가 됩니다. 여기에 host가 NPU를 호출하는 고정 dispatch overhead를 더합니다.

PE 활용률은 실제 필요한 MAC 수를 tile을 채워 실행할 때의 전체 MAC slot으로 나눕니다.

actual_macs  = M × N × K

full_pe_macs
= nt_m × nt_n × nt_k
  × PE_R × PE_C × PE_R

PE utilization = actual_macs / full_pe_macs

작은 행렬이나 배열 크기의 배수가 아닌 행렬에서는 마지막 tile의 일부 PE가 비어 있어 활용률이 낮아집니다. 이것은 뒤에서 살펴볼 Systolic Array 타일링의 핵심 문제와 연결됩니다.

DRAM traffic과 energy는 무엇을 보여줄까

WS에서는 weight tile을 가능한 한 SRAM에 유지해 여러 M tile에 재사용합니다. 전체 weight가 SRAM에 들어가면 한 번 읽을 수 있지만, 들어가지 않으면 M tile마다 다시 읽는 것으로 근사합니다. Input도 남은 SRAM에 들어가는지에 따라 N tile마다 다시 읽을 수 있습니다. Output은 모든 K tile 누적이 끝난 뒤 기록합니다.

총 에너지는 세 항의 합입니다.

MAC energy  = actual_macs × energy_per_mac
DRAM energy = dram_total_bytes × DRAM_energy_per_byte
SRAM energy = sram_accesses × SRAM_energy_per_byte

total_energy
= MAC energy + DRAM energy + SRAM energy

강의 코드는 SRAM 접근을 세밀하게 trace하지 않고 sram_accesses = dram_total_bytes로 단순화했습니다. 따라서 SRAM energy도 실제 bank별 접근 횟수가 아니라, DRAM을 거친 Byte가 적어도 한 번 SRAM도 지난다는 가정에 따른 근사값입니다.

따라서 시뮬레이터의 출력은 단순한 정답 행렬이 아닙니다.

출력 답하려는 질문
C 행렬곱 결과가 맞는가
cycles 얼마나 오래 걸리는가
energy_pj 얼마나 많은 에너지를 쓰는가
dram_read_bytes 외부에서 얼마나 읽는가
dram_write_bytes 외부에 얼마나 쓰는가
pe_utilization PE를 얼마나 채워 쓰는가

이 모델로 세 가지 관계를 확인할 수 있습니다.

  • Workload가 배열보다 작으면 많은 PE가 놀기 때문에 큰 칩이 반드시 효율적이지 않습니다.
  • SRAM이 커져 weight와 input을 더 오래 유지하면 DRAM traffic과 energy가 줄어듭니다.
  • DRAM bandwidth가 높아지면 memory-bound workload의 latency가 줄어듭니다.

다만 수치 자체를 실측값처럼 해석해서는 안 됩니다. Compute와 DRAM overlap을 max() 하나로 단순화했고, SRAM reuse는 들어간다/들어가지 않는다는 binary fit으로 근사했습니다. NoC와 routing 비용, stall과 bank conflict도 포함하지 않았습니다. 이 모델은 실제 칩의 정확한 시간을 예언하기보다, 설계 변수가 성능에 어떤 방향으로 작용하는지 빠르게 비교하기 위한 도구입니다.

13. 여러 NPU 설계 중 무엇을 남겨야 할까

앞에서 만든 NPU simulator를 이용해 설계 공간을 탐색했습니다. NPU는 빠르기만 하거나 에너지만 적게 쓰는 것으로 평가하기 어렵습니다. 일반적으로 latency와 energy 사이에 tradeoff가 있으므로, 여러 설계를 같은 workload에 실행해 함께 비교해야 합니다.

Design Space Exploration(DSE)의 흐름은 다음과 같습니다.

NPUConfig 후보 생성
→ 각 NPU 실행
→ latency / energy 수집
→ Pareto optimal 판별
→ tradeoff 시각화

강의에서는 SRAM, PE Array와 dtype의 조합을 탐색했습니다.

SRAM_SIZES_KB = [1_024, 4_096, 16_384, 65_536]
PE_SHAPES     = [(8, 8), (16, 16), (32, 32), (64, 64)]
DTYPES        = ["int8", "fp16", "fp32"]

SWEEP_WORKLOAD = (256, 4096, 4096)

한 설계의 cycle을 clock frequency로 나누면 latency를 얻고, simulator의 pJ 단위 energy를 mJ로 변환해 같은 축에서 비교할 수 있습니다.

latency = cycles / frequency
1mJ     = 10⁹pJ

Pareto 분석의 핵심은 dominated design을 제거하는 것입니다. 다른 설계가 latency와 energy 모두에서 나쁘지 않고, 적어도 한 지표에서 엄격히 더 좋다면 앞의 설계는 dominated되어 선택할 이유가 없습니다.

설계 Latency Energy
A 1ms 10mJ
B 2ms 5mJ
C 1ms 5mJ

C는 A와 latency가 같으면서 energy가 적고, B와 energy가 같으면서 latency가 짧습니다. 따라서 A와 B는 C에 dominated되고, C만 남습니다. 모든 설계 가운데 이런 방식으로 지배되지 않는 점들을 연결한 선이 Pareto Front입니다. 이것은 하나의 “정답 설계”가 아니라 현재 후보 안에서의 latency-energy tradeoff 경계입니다.

탐색 결과를 해석할 때는 simulator에 없는 비용도 함께 생각해야 합니다.

  • SRAM을 키우면 재사용이 늘어 DRAM traffic과 energy가 줄 수 있지만, 실제 칩에서는 면적과 비용이 증가합니다. 강의 simulator에는 area model이 없습니다.
  • PE Array를 키우면 peak compute는 늘지만, 작은 workload나 edge tile에서는 utilization이 크게 떨어질 수 있습니다.
  • FP32는 정밀도가 높지만 데이터 이동량과 MAC energy가 커지고 throughput이 낮아질 수 있습니다.

그래서 DSE는 가장 큰 SRAM, 가장 많은 PE나 가장 낮은 dtype을 자동으로 고르는 작업이 아닙니다. 같은 workload와 같은 평가 기준에서 설계 변수의 상충관계를 드러내고, 선택할 가치가 있는 후보만 남기는 과정입니다.

14. 기본 MAC을 실제 ASIC·FPGA에 가깝게 바꾸려면 무엇이 필요할까

이전에 다뤘던 기본 MAC은 acc <= acc + a*b라는 핵심 동작을 이해하기에는 충분하지만, 실제 pipeline 안에서 사용하기에는 제어와 안전장치가 부족합니다. 여기에서는 실제 환경에 가깝게 하기 위해 다음 여덟 가지를 보강했습니다.

개선 필요한 이유
DATA_WIDTHACC_WIDTH parameter화 같은 RTL을 INT4·INT8·INT16 구조에 재사용
Active-low reset rst_n 산업에서 흔히 쓰는 reset convention과 비동기 reset 지원
Global enable en stall과 register 갱신 정지, power 절감
clear_acc 새로운 dot product의 경계에서 누적을 다시 시작
in_valid → acc_valid bubble이 있는 pipeline에서 데이터의 유효성 전달
2-stage pipeline multiply와 accumulate의 critical path 분리
Saturation과 overflow flag 범위를 넘을 때 silent wrap-around 방지
RTL의 initial 제거 waveform dump를 testbench로 옮겨 합성 친화적으로 구성

Enable, clear와 valid는 왜 필요할까

실제 하드웨어에는 매 cycle 유효한 데이터가 들어오지 않습니다. pipeline에 bubble이 생길 수도 있고 downstream 상황 때문에 멈춰야 할 수도 있습니다. en=0이면 pipeline register를 갱신하지 않아 상태를 유지하고, stall과 power control의 출발점이 됩니다.

MAC은 하나의 dot product가 끝난 뒤 다음 dot product를 계산할 때 accumulator를 새로 시작해야 합니다.

vector A 계산 완료
→ clear_acc = 1인 첫 입력
→ 기존 acc를 사용하지 않고 0 + product부터 누적

clear_acc가 없으면 앞 벡터의 결과에 다음 벡터의 결과가 계속 더해집니다. 그래서 clear 신호 자체도 입력과 같은 pipeline stage를 따라 이동해야 합니다.

Valid도 데이터와 함께 이동합니다.

Cycle N     : in_valid
Cycle N + 1 : prod_valid
Cycle N + 2 : acc_valid

이 구조는 입력 후 2 cycle에 accumulator 결과가 유효하다는 뜻입니다. 단지 숫자를 계산하는 것만으로는 부족하고, 그 숫자가 언제 의미 있는지도 외부 모듈에 알려야 합니다.

왜 Multiply와 Accumulate를 두 stage로 나눌까

기본 MAC의 critical path에는 multiplier와 adder가 연달아 놓입니다.

Basic MAC
Multiplier → Adder → Register

2-stage MAC
Stage 1: Multiplier → Register
Stage 2: Adder      → Register

Multiplier는 지연이 큰 회로입니다. multiply와 add를 한 cycle 안에 모두 끝내려 하면 clock period를 짧게 만들기 어렵습니다. 중간에 pipeline register를 두면 각 stage의 critical path가 짧아져 더 높은 clock frequency와 timing closure에 유리합니다. 대신 첫 결과의 latency는 늘지만, pipeline이 채워진 뒤에는 새로운 입력을 계속 받을 수 있습니다.

Overflow를 wrap-around가 아니라 saturation으로 처리하는 이유

곱 결과와 accumulator를 더할 때는 먼저 sign extension을 수행하고, accumulator보다 한 bit 넓은 값으로 계산합니다.

INT8 × INT8 → 16bit product
product      → ACC_WIDTH + 1bit로 sign extension
accumulator  → ACC_WIDTH + 1bit로 sign extension
sum          → 확장 sign bit와 원래 sign bit 비교

확장된 최상위 sign bit와 ACC_WIDTH로 잘랐을 때의 sign bit가 다르면 signed overflow가 발생한 것입니다. 기본 정수 연산처럼 wrap-around시키면 큰 양수가 갑자기 큰 음수가 되는 silent corruption이 생길 수 있습니다.

positive overflow → ACC_MAX
negative overflow → ACC_MIN

Saturation은 범위를 넘은 값을 표현 가능한 최댓값이나 최솟값에 고정합니다. Overflow flag는 한 번 발생하면 reset 전까지 유지하는 sticky 방식으로 두어 debug와 telemetry에 사용할 수 있습니다.

최종 구조는 다음과 같습니다.

Input
  ↓
Stage 1: Multiplier
  ↓
Pipeline Register
  ↓
Stage 2: Accumulator
  ↓
Saturation
  ↓
acc + acc_valid + overflow

15. Systolic Array의 동작은 어떤 네 축으로 분석할까

Systolic Array를 “입력을 넣으면 결과가 나온다”로만 보면 내부의 fill, 데이터 이동과 상태 전환을 설명할 수 없습니다. 강의에서는 구체적인 waveform에서 시작해 다음 네 축으로 분석했습니다.

분석 축 묻는 질문 대표 산출물
시간축(Time) 언제 무슨 일이 일어나는가 Cycle Event Table, Timing Diagram, latency 공식
공간축(Space) 특정 cycle에 어느 PE가 일하는가 Array snapshot, utilization heatmap
데이터 흐름축(Data Flow) 특정 값이 어디로 이동하고 무엇에 기여하는가 Data trace
이벤트축(Event) 시스템이 어떤 phase를 거치는가 FSM·phase diagram

권장 순서는 시간 → 공간 → 데이터 흐름 → 이벤트입니다. Waveform의 신호 변화를 먼저 관찰하고, 한 시점의 배열 상태와 한 데이터의 경로를 확인한 뒤, 마지막에 전체 동작을 상태 전이로 추상화합니다.

시간축: 첫 결과와 마지막 결과는 언제 나올까

먼저 clk를 기준으로 reset 해제, weight load, activation stream과 output valid가 발생한 cycle을 표로 기록합니다.

구간 확인할 신호 의미
Reset rst_n 분석 시작 기준
Weight Load weight_start, weight_done PE에 weight를 넣는 구간
Stream act_valid_in activation 입력 구간
Output valid_out, psum_bot_flat 결과가 배열 아래에 도착한 시점

4×4 예시에서는 weight load가 끝난 뒤 activation이 들어오고, 첫 C[0][0]이 나온 다음 각 cycle마다 다음 output column이 대각선 모양으로 도착했습니다.

여기서 첫 결과가 cycle 6인 것은 이전에 봤었던 Python Systolic Array simulator에서 cycle 4였던 것과 기준이 다르기 때문입니다. Python 구현에서의 step()은 배열에 activation을 주입한 시점부터 세었고, Verilog의 RTL timeline은 앞의 weight load와 뒤의 output collect까지 포함했습니다.

Cycle 6: C[0][0]
Cycle 7: C[1][0], C[0][1]
Cycle 8: C[2][0], C[1][1], C[0][2]
Cycle 9: C[3][0], C[2][1], C[1][2], C[0][3]

강의 RTL의 first output과 last output 공식은 다음과 같습니다.

T_first = T_weight_load + R - 1 + T_collect

T_last  = T_input_start + M + R + C - 2

Steady-state throughput
= 1 output column / cycle

이때 “1 output column/cycle”은 새 physical output column이 매 cycle 하나씩 유효해지기 시작한다는 강의 RTL의 표현입니다. 위 표에 여러 scalar가 함께 보이는 것은 이전 cycle에 시작된 column의 다음 row 값도 동시에 내려오기 때문입니다.

여기서 first latency는 배열을 채우고 결과를 collect하기까지의 시간이고, last latency는 마지막 입력과 drain까지 포함합니다. Systolic Array도 첫 결과가 나오기 전까지는 latency가 필요합니다. 장점은 pipeline이 채워진 뒤 규칙적인 throughput으로 결과를 계속 배출한다는 데 있습니다.

공간축: 같은 cycle에 배열 안에서는 무엇이 보일까

관심 있는 cycle 하나를 골라 각 PE의 weight, activation과 partial sum을 기록합니다. 첫 출력 직전의 utilization heatmap은 fill이 대각선 파동처럼 진행되는 모습을 보여줍니다.

Cycle 5

■■■■
■■■□
■■□□
■□□□

Activation은 input skew에 의해 대각선 wave를 만들며 왼쪽에서 오른쪽으로 이동합니다. Partial sum은 위에서 아래로 내려갑니다. Fill 단계에는 일부 PE만 활성화되고, steady state에 가까워질수록 더 많은 PE가 동시에 일합니다. Drain 단계에는 이 과정이 반대로 줄어듭니다.

데이터 흐름축: A[0][0]과 C[0][0]은 어떤 경로를 지날까

하나의 activation을 고르면 연결 구조가 구체적으로 보입니다. A[0][0]은 input port와 skew를 거쳐 PE[0][0]에 들어가고, 다음 cycle에는 오른쪽 PE로 이동하면서 각 열의 weight와 곱해집니다.

한편 C[0][0]은 한 번에 생성되지 않습니다.

PE[0][0]: A00 × B00
PE[1][0]: + A01 × B10
PE[2][0]: + A02 × B20
PE[3][0]: + A03 × B30
Output Collect: C00 저장

즉 activation은 배열 내부를 가로로 이동하고, 하나의 output partial sum은 세로로 내려오면서 조립됩니다. 이 trace를 따라가면 Weight-Stationary라는 이름이 실제 register와 배선에서 어떻게 구현되는지 확인할 수 있습니다.

이벤트축: 전체 동작은 어떤 phase를 거칠까

개별 신호와 데이터 경로를 확인한 뒤에는 전체 동작을 phase로 묶습니다.

RESET
  ↓
WEIGHT_LOAD
  ↓
STREAM
  ↓
DRAIN
  ↓
IDLE
  • RESET에서는 register와 상태를 초기화합니다.
  • WEIGHT_LOAD에서는 weight를 PE에 배치합니다.
  • STREAM에서는 activation을 계속 입력합니다.
  • DRAIN에서는 새 입력이 끝난 뒤 배열 안에 남은 partial sum을 배출합니다.
  • IDLE에서는 다음 작업을 기다립니다.

이 모델은 세 가지를 함께 검증해야 합니다.

  1. Output이 NumPy A @ B와 일치하는지 확인합니다.
  2. First와 last latency가 timing 공식과 일치하는지 확인합니다.
  3. Steady state에서 한 cycle당 한 output column이 나오는지 확인합니다.

강의의 RTL 예시는 전체 weight를 동시에 load하고, output collect에는 한 cycle register만 두었습니다. 별도의 de-skew가 없고, K > R이면 외부 tile accumulation이 필요합니다. 따라서 분석 공식과 waveform은 이 구현의 구조와 가정을 기준으로 읽어야 합니다.

16. 처음 보는 Systolic Array RTL은 어떤 순서로 읽어야 할까

RTL 파일을 처음부터 한 줄씩 읽으면 최상위 module과 하위 module을 계속 오가게 됩니다. 강의에서는 두 번의 pass로 나눴습니다.

Pass 1: Top-down
systolic_top에서 시작
→ module, port와 submodule instance만 확인
→ 전체 연결 구조와 block diagram 작성

Pass 2: Bottom-up
mac_prod
→ input_skew
→ weight_loader
→ output_collect
→ pe_array
→ systolic_top

첫 번째 pass의 목적은 구현을 이해하는 것이 아니라 시스템의 지도를 만드는 것입니다. Weight path, activation path와 compute path가 어디에서 시작해 어디로 가는지를 정리합니다.

Weight:
weight_matrix → weight_loader → load_weight_flat

Activation:
act_in_flat → input_skew → act_skewed

Compute:
(load_weight_flat + act_skewed)
→ pe_array
→ output_collect
→ result_flat

두 번째 pass에서는 의존성이 없는 leaf module부터 올라옵니다. 각 파일은 다음 일곱 순서로 읽습니다.

순서 확인 항목 질문
1 Header comment 이 module의 설계 의도는 무엇인가
2 Parameter 어떤 폭과 크기를 바꿀 수 있는가
3 Port list 입력·출력·제어 신호는 무엇인가
4 Wire / Reg 내부 연결과 상태는 무엇인가
5 assign 즉시 반응하는 combinational logic은 무엇인가
6 always clock에 따라 바뀌는 sequential state는 무엇인가
7 generate / initial 어떤 구조를 복제하고 어떤 simulation 동작을 하는가

always block은 sensitivity list, reset branch, active branch와 hold 상태 순서로 봅니다. 예를 들어 always @(posedge clk or negedge rst_n)이면 clock rising edge에 동작하고, rst_n=0이 되는 순간에는 clock과 관계없이 reset됩니다. else if (en) 뒤에 별도 else가 없으면 en=0에서 register는 기존 값을 유지합니다.

연산자 의미 주된 사용
<= Non-blocking Sequential logic
= Blocking Combinational logic

Non-blocking assignment 여러 개는 같은 clock edge의 이전 값을 기준으로 평가되고 함께 갱신됩니다. 그래서 Systolic Array에서 activation과 partial sum이 같은 cycle에 각 이웃으로 이동하는 구조를 코드 순서와 혼동하지 않아야 합니다.

generate와 input_skew는 무엇을 만드는가

genvar는 일반적인 runtime 변수나 register가 아니라 generate loop 전용 변수입니다. generate for는 매 clock 실행되는 반복문이 아니라 elaboration, 즉 compile 단계에서 반복되는 하드웨어 구조를 실제 instance들로 펼칩니다. ROWS=4, COLS=4라면 이 단계에서 16개의 PE가 만들어집니다.

begin : g_row처럼 이름을 붙인 generate block은 각 instance에 계층 경로를 제공합니다.

g_row[2].g_col[1].pe
→ row=2, col=1 위치의 PE instance

Flat bus에서 각 PE의 값을 잘라낼 때는 indexed part-select를 사용합니다.

x[15 -: 8]  // x[15:8], 기준 bit부터 아래 방향으로 8bit
x[0  +: 8]  // x[7:0],  기준 bit부터 위 방향으로 8bit

input_skew는 행마다 서로 다른 길이의 shift register를 두어 activation의 도착 시점을 정렬합니다. 예를 들어 row 2의 pipe[0:1]은 2 cycle delay를 만들고, 각 activation이 필요한 PE에서 weight와 같은 cycle에 만나도록 대각선 wavefront를 만듭니다.

pe_array는 계산 알고리즘이 아니라 배선도다

pe_array module의 역할은 ROWS×COLS개의 PE를 만들고 이웃끼리 연결하는 것입니다. 알고리즘, FSM이나 scheduler가 아니라 assembly와 wiring module에 가깝습니다.

Activation (→):
left → PE00 → PE01 → PE02 → PE03 → right
        ↓psum   ↓psum   ↓psum   ↓psum
left → PE10 → PE11 → PE12 → PE13 → right
        ↓psum   ↓psum   ↓psum   ↓psum
left → PE20 → PE21 → PE22 → PE23 → right
        ↓psum   ↓psum   ↓psum   ↓psum
left → PE30 → PE31 → PE32 → PE33 → right
        ↓       ↓       ↓       ↓
      bottom output

Partial sum은 각 column에서 위(0) → 아래로 전달됩니다.

가로 activation wire는 PE가 4개라면 입구와 출구를 포함해 5개 지점이 필요하므로 [0:COLS] 범위를 갖습니다. 세로 partial-sum wire도 같은 이유로 [0:ROWS] 범위를 갖습니다.

generate의 이중 for-loop는 ROWS × COLS개의 mac_prod를 복제합니다. Flat bus에서는 i * COLS + j로 각 PE의 load 신호와 weight slice를 찾습니다. 내부 PE는 같은 패턴의 반복이므로 네 모서리를 먼저 확인하는 것이 빠릅니다.

  • PE[0][0]은 외부 activation과 0인 top partial sum을 받습니다.
  • 오른쪽 끝 PE는 activation을 외부로 내보냅니다.
  • 맨 위 PE는 top partial sum을 받고, 맨 아래 PE는 완성된 결과를 내보냅니다.
  • activation output은 h_act[i][j+1], partial-sum output은 v_psum[i+1][j]로 연결됩니다.

이렇게 읽으면 “weight를 저장하고, activation을 오른쪽으로 전달하며, partial sum을 아래로 누적한다”는 PE의 역할과 배열의 연결 구조를 분리해 설명할 수 있습니다.

17. 행렬이 Systolic Array보다 크거나 모양이 맞지 않으면 어떻게 될까

실제 행렬 크기에 맞춰 Systolic Array를 무한히 키울 수는 없습니다. 하드웨어는 16×16인데 50×50 행렬을 계산한다면 각 축을 tile로 나눠 반복 실행해야 합니다.

ceil(50 / 16) = 4

각 축의 tile 크기:
16, 16, 16, 2

마지막 M·N tile의 모양은 16×2, 2×16과 2×2가 됩니다. 하지만 물리적 배열은 여전히 16×16입니다.

유효한 tile 사용 PE 비어 있는 PE
16×16 256 0
16×2 32 224
2×16 32 224
2×2 4 252

Edge tile의 유효한 원소가 적어도 같은 16×16 하드웨어를 같은 방식으로 구동합니다. 마지막 2×2 tile은 4개의 PE만 유효한 계산을 하고 나머지 252개는 놀게 됩니다. 이것이 행렬 모양과 tile 경계 때문에 utilization이 떨어지는 이유입니다.

강의의 analytical model은 타일 실행 시간을 다음처럼 계산했습니다.

compute_cycles
= nt_m × nt_n × nt_k × cycles_per_tile
  + (PE_R + PE_C - 1)

앞의 곱은 모든 M·N·K tile 조합을 실행하는 비용이고, 마지막 항은 Systolic pipeline을 채우고 비우는 fill/drain 비용입니다. Edge tile 안에 유효한 값이 하나뿐이어도 cycles_per_tile을 다르게 바꾸지 않습니다. 모양마다 별도의 하드웨어 경로를 만들면 유연성과 면적·전력 비용이 커지기 때문입니다.

타일링은 큰 행렬을 제한된 하드웨어에서 처리하게 해 주지만 공짜는 아닙니다.

  • Tile마다 weight와 input을 옮기고 partial sum을 이어야 합니다.
  • Tile 수가 많아질수록 tile 실행과 데이터 이동을 반복하는 비용이 늘어납니다. 다만 강의의 analytical 식에서는 fill/drain 항을 전체 실행에 한 번만 더하는 것으로 단순화했습니다.
  • 행렬 크기가 배열 크기의 배수가 아니면 edge tile에서 utilization이 낮아집니다.
  • SRAM이 충분하지 않으면 tile 재사용이 줄고 DRAM traffic이 늘어납니다.

따라서 PE Array는 클수록 무조건 좋은 것이 아닙니다. 목표 workload의 M·N·K 분포와 batch, SRAM 용량을 함께 보고 자주 발생하는 tile 모양에서 PE를 얼마나 채울 수 있는지 판단해야 합니다.

마치며

이 과정은 Python 코드의 실행 속도에서 시작했지만, 마지막에는 같은 질문을 NPU 전체로 확장했습니다. 연산 자체보다 그 연산이 어느 계층에서 실행되고, 데이터가 어디에 머물며, 언제 어떤 PE로 이동하는지가 성능과 에너지를 결정했습니다.

흐름을 다시 정리하면 다음과 같습니다.

Python 실행 계층
→ Cache와 SIMD
→ Roofline
→ MatMul loop와 Stationary Dataflow
→ Weight-Stationary Systolic Array
→ NPU Config와 MAC RTL
→ Functional + Analytical Simulator
→ Pareto DSE
→ Pipeline, RTL 분석과 Tiling

가장 인상적인 부분은 Systolic Array였습니다. MAC은 단순한 연산이지만, weight를 PE에 고정하고 activation과 partial sum을 일정한 박자로 흐르게 하면 큰 병렬 계산 구조가 됩니다. 반대로 fill과 drain, edge tile, SRAM 부족과 메모리 traffic을 무시하면 PE의 peak 성능은 실제 성능으로 이어지지 않습니다.

결국 AI 반도체 아키텍처를 설계한다는 것은 MAC 개수를 늘리는 일이 아니라, 목표 workload에 맞춰 데이터 타입, 재사용 방식, PE 배열, 메모리 계층과 제어 구조를 함께 결정하는 일입니다. 그리고 그 결정은 하나의 peak 수치가 아니라 정확성, latency, energy, traffic과 utilization을 함께 측정할 때 비로소 비교할 수 있습니다.

이 글은 20일 간 학습하면서 정리한 내용을 Codex (gpt-5.6-sol) 를 이용하여 정리한 것입니다.

반응형
Posted by Taekhan
|