Company

Resources

Company

Resources

Product

Optimium 탐구(1): 추론 최적화 기법

Optimium 탐구(1): 추론 최적화 기법

Optimium 탐구(1): 추론 최적화 기법

이번 게시물에서는 에너자이의 자동 AI 추론 최적화 엔진 Optimium이 구체적으로 어떤 최적화 프로세스를 통해 학습된 AI 모델의 정확도를 보존하면서 대상 하드웨어 내 추론 속도를 가속화할 수 있는지 알아보고자 합니다.

Sungmin Woo

May 19, 2024

안녕하세요, 에너자이 사업개발팀 우성민(Sungmin Woo)입니다. 지금까지 AI 최적화의 개념, 대표적인 추론 최적화 프레임워크인 TFLite, 온디바이스 AI 등 Edge AI 시장 트렌드를 이해하기 위한 기초 지식부터 에너자이 연구개발팀이 연구를 진행하면서 얻은 인사이트까지 다양한 주제의 게시물들을 업로드해왔는데요.

이번 게시물에서는 에너자이의 자동 AI 추론 최적화 엔진 Optimium이 구체적으로 어떤 최적화 프로세스를 통해 학습된 AI 모델의 정확도를 보존하면서 대상 하드웨어 내 추론 속도를 가속화할 수 있는지 알아보고자 해요.

원활한 이해를 위해 본문의 예시들은 단순한 구조의 Python 코드로 구성하였다는 점 참고 부탁 드리며, 이 글을 통해 독자 분들이 AI 추론 최적화와 Optimium에 대해 좀 더 깊이 있게 이해하고 유용한 인사이트를 얻을 수 있기를 바랍니다.

추론 최적화, 왜 필요한가?

추론 최적화는 대상 하드웨어 환경 내 AI 모델 추론 성능 향상을 목적으로 하는 모든 프로세스를 의미하는 매우 광범위한 개념이지만, 그 본질은 필요한 연산들을 최대한 빠르고 효율적으로 수행할 수 있는 형태의 코드를 구현하는데 있습니다.

추론 최적화가 중요한 이유는 같은 연산이라도 어떤 방식으로 수행하느냐에 따라 추론 속도 측면에서 확연한 차이가 나타나기 때문이에요.

간단한 예시를 들어서 설명을 드려볼게요. 아래에 행렬 X,Y가 주어졌을 때 Z=|X+Y| 를 구하기 위한 코드 두 개가 주어져 있습니다.

x = np.random.random((1000,1000))
y = np.random.random((1000,1000))
z = np.random.random((1000,1000))

start = time.time()
for i in range(1000):
  for j in range(1000):
    z[i,j] = x[i,j] + y[i,j]

for i in range(1000):
  for j in range(1000):
    if z[i,j] > 0:
      z[i,j] = z[i,j]
    else:
      z[i,j] = -z[i,j]
print(time.time() - start)

<code 1>

x = np.random.random((1000,1000))
y = np.random.random((1000,1000))
z = np.random.random((1000,1000))

start = time.time()
for i in range(1000):
  for j in range(1000):
    temp = x[i,j] + y[i,j]
    if temp > 0:
      z[i,j] = temp
    else:
      z[i,j] = -temp
 print(time.time() - start)

<code 2>

두 개의 코드의 차이는 다음과 같이 요약할 수 있어요.

  • [code 1] : X+Y를 구하는 Loop와 다시 절댓값을 취하는 Loop로 구성

  • [code 2] : X+Y와 절댓값을 하나의 Loop에서 수행

두 개의 코드 결과는 정확히 일치해요. 하지만 [code 1]의 코드를 수행하는데 35초가 걸리는 반면에 [code 2]의 코드는 21초 안에 Z=|X+Y| 값을 구할 수 있어요 (AMD Ryzen9 7950x 기준).

Loop 하나 합쳤을 뿐인데 추론 속도가 1.6배 이상 빨라질 수 있어요!

💡 <Advanced>

일반적으로 AI 모델의 추론 속도는 필요한 연산을 얼마나 빠르게 수행하는지 나타내는 Computing Speed와 얼마나 빠르게 메모리에 접근하여 정보를 저장하고 불러오는지 나타내는 Memory Access Speed에 의해 결정되는데요. 위 사례의 경우 메모리에 접근하여 정보를 저장하고 불러오는데 소요되는 시간을 단축함으로써 추론 속도를 개선한 사례로 볼 수 있습니다.

또한, 메모리에 정보를 저장하는 작업을 Store, 메모리에서 필요한 정보를 불러오는 작업을 Load라고 하는데요. Z에 접근하는 횟수 기준으로 [code 1]의 코드가 Store 2회, Load 1회로 총 3회의 메모리 접근이 필요한 반면에 [code 2]의 코드는 Store 1회의 메모리 접근으로만 연산을 수행할 수 있습니다.

간단한 사례를 통해 최적화된 코드가 어떻게 AI 모델의 추론 속도 향상에 기여할 수 있는지 알아보았는데요. 이제 실제로 Optimium에 적용된 대표적인 최적화 기법 몇 가지를 살펴보면서 Optimium이 어떻게 추론 최적화를 수행하는지 알아보도록 하겠습니다.

최적화 in Optimium

SIMD(Single Instruction Multiple Data)

SIMD란 하나의 명령어로 여러 개의 데이터를 한 번에 처리하여 하나의 명령어로 하나의 데이터를 처리하는 SISD(Single Instruction Single Data) 기법 대비 필요 연산 횟수를 현저히 줄여 추론 속도 향상에 기여할 수 있는 최적화 기법입니다. 아래 그림은 8개의 숫자로 이루어진 두 개의 Vector 덧셈이라는 동일한 Task를 수행하는데 필요한 SISD 및 SIMD의 연산 횟수를 나타낸 것입니다.

즉, SISD(왼쪽 그림)는 한 번에 하나의 데이터만 처리할 수 있으므로 총 8번의 덧셈을 수행해야 하지만, SIMD(오른쪽 그림)는 한 번에 4개의 데이터를 처리할 수 있으므로 단 2번의 덧셈으로 연산을 끝낼 수 있습니다. 물론 도출되는 결과값은 동일하죠! (단, 미세한 차이는 있을 수 있어요)

아래 Python numpy code를 보시면 좀 더 이해하기 쉬우실 거에요. 두 코드 모두 2D convolution을 수행하는 연산인데요. [code 3]의 코드는 각 성분별로 곱셈과 덧셈을 수행하는 반면 [code 4]의 코드는 4개의 연속적인 값들을 읽어와서 한번에 SIMD 연산을 수행하기 때문에 적은 연산 횟수로 훨씬 빠르게 2D convolution 연산을 수행할 수 있어요. 아마도 Numpy에 익숙하신 분들은 이미 [code 4]와 유사한 형태의 코드를 자주 사용하실 거에요. (이미 여러분 모두 SIMD를 쓰고 있었다는 사실!)

# SISD
for i in range(16):
  for j in range(16):
    for kh in range(3):
      for kw in range(3):
        for ch in range(16):
          output[0,i,j,ch]      += input[0, i+kh, j+kw, ch] 
                                    * weight[kh, kw, ch]

<code 3>

# SIMD
for i in range(16):
  for j in range(16):
    for kh in range(3):
      for kw in range(3):
        for ch in range(0,16,4):
          output[0,i,j,ch:ch+4] += input[0,i+kh,j+kw,ch:ch+4]
                                     * weight[kh, kw ch:ch+4]

<code 4>

단, 모든 상황에서 SIMD를 사용할 수 있는 건 아니에요. 데이터가 메모리 내부에 연속적으로(contiguous) 배치되어 있는 경우에만 적용 가능합니다. 즉, SIMD를 통해 [0,16] 구간을 [0,4][4,8][8,12][12,16] 4개의 연속된 구간으로 분할하여 필요 연산 횟수를 줄이는 것은 가능하지만, (0,1,5,6)(2,3,7,8)과 같이 불연속적인 데이터에 SIMD를 적용하는 것은 불가능합니다.

뿐만 아니라, 하드웨어별로 지원하는 SIMD 기능(Instruction set) 및 한 번에 처리할 수 있는 데이터 양이 다르기 때문에 대상 하드웨어 환경에 적합한 형태로 SIMD 기법을 적용하는 것이 중요해요. 대표적인 하드웨어별 SIMD 아키텍처는 아래와 같습니다.

  • x86_64: SSE(128bit), AVX256, AVX512

  • ARM: Neon(128bit)

Unroll(Loop Unrolling)

Unroll이란 코드 내의 Loop를 풀어서 나열함으로써 Loop로 인해 발생하는 Overhead 및 메모리 접근 횟수감소 등을 통해 추론 속도를 향상시키는 최적화 기법이에요. Unroll 기법을 통한 추론 속도 향상에 대한 자세한 설명은 아래 내용 참고 부탁 드립니다.

Loop Overhead Reduction

  • Loop는 매우 편리한 기능이지만 다음 Loop로 넘어갈 때마다 수행해야 하는 조건문 수행, 동기화, Index 증가 등의 Overhead가 발생해요. Unroll을 통해 Loop 횟수를 줄이고 Overhead로 인해 소요되는 시간을 단축할 수 있어요.

Memory Access Reduction

  • 앞서 말씀드린 것처럼 추론 속도는 연산 속도에도 영향을 받지만 메모리로부터 값을 읽고 쓰는데 필요한 메모리 접근 시간에도 크게 영향을 받는데요. 그래서 이 메모리 접근 횟수를 줄일 수 있다면 그만큼 추론 속도를 높일 수 있습니다.

  • 아래 예시는 2x2 kernel을 가진 2D convolution 코드의 메모리 접근 횟수를 비교한 것인데요 (in channel=out channel=1인데 중요하지 않아요). [그림 2]이 Unroll 하지 않은 코드, [그림 3]이 2만큼 Unroll 한 코드예요.

for i in range(0,4):
  for j in range(0,4):
    output[i,j,0] += input[i,j,0] * weight[0,0,0]
    output[i,j,0] += input[i,j+1,0] * weight[0,1,0]
    output[i,j,0] += input[i+1,j,0] * weight[1,0,0]
    output[i,j,0] += input[i+1,j+1,0] * weight[1,1,0]
  • 각 Loop에서 Input에 접근해야 하는 위치가 짙은 파란색으로 표시되어 있어요. 즉, i=0, j=0일 때는 좌측 상단 2x2 부분을 읽어야 하고, i=0, j=1일 경우 한 칸 오른쪽으로 이동하여 대응되는 부분을 읽어야 하죠

  • 그렇게 쭉 Loop를 반복하면서 읽다 보면, 대부분의 Input을 4번씩 읽어야 해요

for i in range(0,4):
  for j in range(0,4,2):
    output[i,j,0] += input[i,j,0] * weight[0,0,0]
    output[i,j,0] += input[i,j+1,0] * weight[0,1,0]
    output[i,j,0] += input[i+1,j,0] * weight[1,0,0]
    output[i,j,0] += input[i+1,j+1,0] * weight[1,1,0]

    output[i,j+1,0] += input[i,j+1,0] * weight[0,0,0]
    output[i,j+1,0] += input[i,j+1+1,0] * weight[0,1,0]
    output[i,j+1,0] += input[i+1,j+1,0] * weight[1,0,0]
    output[i,j+1,0] += input[i+1,j+1+1,0] * weight[1,1,0]
  • [그림 3]의 경우 j loop에 대해 2만큼 Unroll을 해서 Loop body에 4줄이 추가되어 한 번에 읽어야 할 Input이 늘어났어요. 여기에서 input[i,j+1,0]input[i+1,j+1)이 두 번씩 등장하기 때문에 각 Loop에서 접근하는 Input의 범위는 2x4가 아닌 2x3이에요.

  • 그렇기 때문에 각 Loop에서 접근해야 할 위치는 i=0, j=0일 때 좌측 상단 2x3 영역, i=0, j=2일 때는 두 칸 오른쪽으로 움직인 2x3 영역이 돼요.

  • 위 사례에서 [그림 2]의 Original 코드는 64회의 메모리 접근이 필요하나, Unroll 기법이 적용된 [그림 3]의 코드는 48회의 메모리 접근만으로 추론을 수행할 수 있어요.

💡 <Advanced>

물론 Unroll이 만능은 아닙니다! 지나치게 Unroll을 많이 하면 Register가 부족하여 Register spilling이 일어날 수 있습니다. Register spilling은 급격한 추론 속도 저하를 유발하기 때문에 최적의 Unroll number를 찾아내는 것이 매우 중요한데요. 난이도가 매우 높은 Task이지만, Optimium은 Auto-tuning을 통해 최적의 Unroll number를 찾아낼 수 있습니다. Auto-tuning은 추후 별도의 게시물을 통해 좀 더 자세히 알아볼 예정입니다!

지금까지 Optimium에 적용된 대표적인 최적화 기법 두 가지 SIMD와 Unroll에 대해서 알아보았는데요. 앞으로 SIMD와 Unroll 외의 다양한 최적화 기법들에 대해서도 소개 드릴 계획이니, 많은 관심 부탁 드립니다.

Why Optimium?

AI 추론 최적화 또는 코드 최적화에 어느 정도 관심이 있으신 분들은 잘 아시겠지만, SIMD나 Unroll은 비교적 널리 알려진 최적화 기법들이에요. 하지만 현 시점에서 이러한 최적화 기법들을 성공적으로 추론 최적화 엔진 내에 구현하는 것은 난이도가 매우 높은 Task인데요. 그 이유는 알고리즘 종류, CPU 사양, Loop 파라미터 등 수많은 변수에 따라 최적화된 코드가 변화하기 때문입니다. 추론 최적화 프로세스에 영향을 미치는 요인들에 대한 자세한 설명은 아래 내용 참고 부탁 드려요.

알고리즘 종류: 동일한 연산도 다양한 알고리즘을 통해 다른 방식으로 구현할 수 있습니다. Convolution 연산만 고려하더라도 아래와 같은 4개 이상의 알고리즘이 존재합니다.

  • Winograd algorithm

  • FFT based algorithm

  • Matmul algorithm

  • Indirect buffer based GEMM algorithm

CPU 사양: 동일한 알고리즘으로 구현된 연산이라도 아래와 같은 하드웨어 사양에 따라 성능 편차가 나타날 수 있습니다.

  • Cache size

  • Instruction set

  • Memory bandwidth

Loop 파라미터: 아래와 같은 Loop 파라미터들도 연산의 추론 성능에 영향을 미칠 수 있습니다.

  • Unroll number

  • Vector size

  • Tile size

이러한 변수들의 조합을 모두 고려하여 최적화된 코드들을 직접 구현하는 것은 불가능에 가깝기 때문에 대부분의 추론 최적화 엔진들은 제한된 범위의 시나리오별(특정 알고리즘 종류, CPU 사양, 경험에 기반한 Loop 파라미터) 최적화된 코드를 수동으로 구현하여 지원하고 있습니다.

아래 이미지는 오픈소스 추론엔진 XNNPACK(TensorFlow, TensorFlow Lite, Pytorch 등의 Backend로 사용되는 가장 대표적인 추론 엔진)과 Tencent NCNN의 Github source code list인데요. 각각 Transpose 연산과 Convolution 연산을 지원하기 위해 20개 이상의 소스 코드를 구현하고 있습니다. 1) 알고리즘이 Floating point 연산인지 또는 Int 연산인지, 2) Neon instruction을 사용하는지, 3) Unroll number를 어떻게 설정하였는지 등의 변수들에 따라 최적화된 코드를 일일이 구현해야 하기 때문이죠.

문제는 이렇게 20개 이상의 소스를 만들었지만 여전히 Suboptimal하다는 것입니다! Input shape, kernel size, stride, CPU 사양 등의 요인들이 변화하면 최적의 코드 또한 달라지기 때문이죠. 하지만 모든 경우의 수에 대하여 일일이 최적화된 코드를 직접 구현한다? 불가능한 이야기입니다!

하.지.만! Optimium은 다릅니다. Optimium은 Metaprogramming을 지원하는 자체 개발 프로그래밍 언어 Nadya를 기반으로 기존 추론 최적화 엔진들 대비 폭넓은 영역에 대한 자동 추론 최적화를 지원합니다. Metaprogramming 개념을 처음 접하시는 분들이 많으실 텐데요. 간단하게는 코드를 자동으로 만드는 코드라고 생각하시면 됩니다.
(Nadya 및 Metaprogramming은 추후 게시물에서 좀 더 자세히 다룰 예정이니, 조금만 기다려주세요!)

아래의 그림은 Optimium이 다양한 경우의 수에 대하여 c=a+b 연산을 수행하는 최적의 코드를 구현하는 과정을 예시적으로 나타낸 그림입니다. 실제 사용자가 코드를 작성하면([그림 5] 상단의 코드 참조), Optimium이 이를 컴파일하여 Unroll number=2인 경우(하단 좌측)와 Unroll number=3인 경우(하단 우측)에 실행될 최적화된 코드를 생성한 것을 확인하실 수 있는데요. 사용자가 하나의 코드만 구현하면 Optimium이 자동으로 Unroll number를 비롯한 무수히 많은 여러 Configuration에 대한 코드들을 생성한 뒤 추론 속도를 비교하여 가장 최적화된 코드를 도출할 수 있습니다.

Optimium!

다양한 추론 최적화 기법들을 집대성하여 개발된 Optimium은 현재 베타 테스트 진행 중이며, 다양한 하드웨어 환경에 대하여 기존 추론 최적화 엔진들 대비 우수한 성능을 보여 다양한 업체들의 관심을 받고 있어요. 치열한 연구개발이 현재 진행형으로 이루어지고 있지만, 이미 XNNPack 대비 1.5배 이상의 추론 속도를 보일 정도로 뛰어난 성능을 보유하고 있습니다.

좀 더 다양한 환경 내 Optimium의 성능 지표를 확인하고 싶으신 분들은 다음 링크 참고 부탁 드려요

👉 https://perf.enerzai.com

Optimium은 위에서 언급된 최적화 기법들 외에도 Operation fusion, Mixed precision quantization 등 다양한 기술들을 지원하는 에너자이 추론 최적화 기술의 정수라고 할 수 있어요. 앞으로도 Optimium에 적용된 흥미로운 기술들에 대한 게시물들을 지속적으로 업로드할 예정이니 많은 관심 부탁 드리며, 현재 진행 중인 Optimium Beta Test 참여에 관심 있으신 분들은 아래 링크를 통해 신청 부탁 드립니다!

https://wft8y29gq1z.typeform.com/to/Sv9In4SI

Optimium

Optimium

Solutions

Resources

ENERZAi

Copyright ⓒ ENERZAi Inc. All Rights Reserved

Business number: 246-86-01405

Email: contact@enerzai.com

Call: +82 (2) 883 1231

Address: 06140 27, Teheran-ro 27-gil, Gangnam-gu, Seoul, Republic of Korea

Optimium

Optimium

Solutions

Resources

ENERZAi

Copyright ⓒ ENERZAi Inc. All Rights Reserved

Business number: 246-86-01405

Email: contact@enerzai.com

Call: +82 (2) 883 1231

Address: 06140 27, Teheran-ro 27-gil, Gangnam-gu, Seoul, Republic of Korea

Optimium

Optimium

Solutions

Resources

ENERZAi

Copyright ⓒ ENERZAi Inc. All Rights Reserved

Business number: 246-86-01405

Email: contact@enerzai.com

Call: +82 (2) 883 1231

Address: 06140 27, Teheran-ro 27-gil, Gangnam-gu, Seoul, Republic of Korea