Company

Resources

Company

Resources

Product

Optimium 탐구(2): 제 이름은 Nadya, 고성능 컴퓨팅을 위한 프로그래밍 언어죠🕵️

Optimium 탐구(2): 제 이름은 Nadya, 고성능 컴퓨팅을 위한 프로그래밍 언어죠🕵️

Optimium 탐구(2): 제 이름은 Nadya, 고성능 컴퓨팅을 위한 프로그래밍 언어죠🕵️

AI 모델의 연산 속도를 가속하기 위해 저희는 고성능 컴퓨팅을 위한 새로운 언어 Nadya를 직접 개발하였고, 이를 Optimium에 적극 활용하고 있습니다. MADE BY ENERZAi. 오늘은 에너자이가 자체 개발한 ‘Nadya’라는 언어에 대해 자세히 설명하겠습니다

Sewoong Moh

April 2, 2024

안녕하세요. AI 추론 최적화 엔진을 만들고 있는 Optimium 팀의 모세웅(Sewoong Moh)입니다. 이전 게시물에서 말씀드린 것처럼 Optimium은 AI 개발자들을 위한 강력한 추론 엔진으로, 최적의 AI 모델을 쉽고 빠르게 배포할 수 있도록 지원합니다. AI 모델의 연산 속도를 가속하기 위해 저희는 고성능 컴퓨팅을 위한 새로운 언어 Nadya를 직접 개발하였고, 이를 Optimium에 적극 활용하고 있습니다. MADE BY ENERZAi. 오늘은 에너자이가 자체 개발한 ‘Nadya’라는 언어에 대해 자세히 설명하겠습니다.

Optimium 탐구(1): 추론 최적화 기법안녕하세요, 에너자이 사업개발팀 우성민(Sungmin Woo)입니다. 지금까지 AI 최적화의 개념, 대표적인 추론 최적화 프레임워크인 TFLite, 온디바이스 AI 등 Edge AI 시장 트렌드를 이해하기 위한…medium.com

이전 내용을 잠시 복습하고 넘어갈까요? AI 모델 배포를 위해 Optimium을 활용하는 것의 대표적인 장점들은 아래와 같습니다!

추론 최적화를 위한 소모되는 불필요한 시간을 아낄 수 있습니다.

  • 배포 일정에 쫓겨 AI 모델은 한 땀 한 땀 수동으로 최적화할 필요 없이, Optimium을 통해 자동으로 최적 배포를 위한 모델의 추론 최적화를 수행할 수 있습니다.

기존 프레임워크와 높은 호환성을 자랑합니다.

  • 다양한 프레임워크에서 사용할 수 있는 기본적인 연산자를 지원하고, 설계상 추가로 필요한 연산자가 있다면 언제든 쉽게 확장 가능합니다.

지원되는 Architecture 범위 내에선 제조사에 상관없이 유연한 지원이 가능합니다.

  • 현재 지원하는 마이크로 아키텍처 (x86/x64/Arm) 범위 내에서는 공급업체/브랜드에 관계없이 어디에서나 작동합니다.

Optimium이 다른 State-of-the-art 추론 엔진들보다 압도적인 성능을 제공할 수 있는 것은 주어진 타겟 하드웨어에 맞춰 자동으로 코드를 생성하고 최적화할 수 있기 때문입니다. 이러한 과정은 범용적인 목적으로 만들어진 기존 프로그래밍 언어(ex. Python 혹은 C++)로 구현 불가능한 부분이기 때문에, 저희는 Optimium만을 위한 새로운 프로그래밍 언어 Nadya를 직접 개발했습니다. Nadya는 간단하게 작성할 수 있으면서도 자동 코드 생성을 언어 레벨에서 지원하고, 다양한 타겟 하드웨어에 맞는 최적화 파이프라인을 내장하고 있습니다.

Nadya가 강력한 이유 1 : Metaprogramming

Metaprogramming이란 프로그램이 스스로 프로그램을 짤 수 있도록 하는 것을 의미합니다. 쉽게 말해, 개발자가 직접 실제로 돌아갈 코드를 프로그래밍하는 것이 아닌, 앞으로 실행될 코드를 생성하는 프로그램을 만듭니다. Python도 일부 Metaprogramming과 비슷한 기능인 Decorator이나 Metaclass가 있습니다. C++의 경우에도 템플릿이나 매크로를 통한 Metaprogramming 기능이 일부 지원되지만, 대규모 데이터를 다루거나 복잡한 코드를 생성하고자 하면 제한적인 기능 때문에 매우 복잡해져서 사실상 사용이 어렵습니다.

하지만, Nadya는 설계부터 이러한 Metaprogramming을 쉽게 지원할 수 있도록 많은 노력을 기울였습니다. Nadya를 사용하면 특별히 복잡한 기능을 배울 필요가 없이 개발자가 일반적인 데이터를 다루듯이 아주 쉽게 코드 생성을 할 수 있습니다. 따라서 Nadya는 지금까지 그 어떤 프로그래밍 언어도 지원하지 못하는 아주 강력하고 유연한 Metaprogramming을 지원할 수 있게 되었습니다.

💡 <Metaprogramming>
· 객체를 생성하지 않더라도 타입에 어떠한 값을 부여할 수 있고, 또 그 타입들을 가지고 연산을 할 수 있습니다.

· 컴파일 타임에 정적으로 계산되는 부분을 미리 계산할 수 있어 프로그램 실행 속도를 향상할 수 있다는 장점이 있습니다.

· 하지만 컴파일 타임에 연산하는 것이기 때문에 디버깅이 불가능하여 메타 프로그래밍으로 작성된 코드는 버그를 찾는 것이 매우 어렵습니다.

그렇다면 Metaprogramming이 Optimium의 성능 최적화에 왜 중요할까?

복잡한 Metaprogramming을 거치지 않고 그냥 짜면 안 되느냐는 질문이 있을 수도 있습니다. 물론 그래도 되지만, 그렇다면 개발자는 훨씬 더 긴 코드를, 여러 번 만들어야 할 것입니다. 대부분의 컴퓨터는 입맛이 아주 까다로워서 빠르게 동작하는 프로그램의 형태가 각자 다릅니다. 같은 행렬 연산 알고리즘이라고 하더라도, 스마트폰에 많이 사용되는 Arm 기반 칩에서 빠르게 동작하는 코드, Intel 칩에서 잘 동작하는 코드는 각자 다릅니다. 심지어 같은 Intel이나 Arm 기반 칩이더라도, 종류나 세대에 따라 달라지기도 합니다. 이렇게 모든 경우의 수를 고려하려면 정말로 많은 코드를 짜야 합니다. 실제로 TensorFlow Lite 등 기존 추론 엔진의 경우 레이어 1개를 최적화하기 위해 정말 많은 종류의 알고리즘을 하드웨어별로 다르게 제작합니다.

하지만 Nadya와 같은 Metaprogramming이 가능하다면 이야기가 달라집니다. 컴파일 시간에 이미 어느 하드웨어에서 실행될지 알 수 있으므로 해당 하드웨어에 맞게 코드를 생성하기만 하면 추가적인 수고로움을 줄일 수 있습니다. 따라서 Metaprogramming을 사용한 100줄의 Nadya 프로그램은 다른 프로그래밍 언어로 만들어진 1,000줄 ~ 10,000줄의 코드보다 뛰어난 성능을 낼 수 있습니다. 바로 이러한 장점 때문에 저희 팀은 효율을 극대화하여 적은 인원이지만 많은 개발자가 참여한 기존의 추론 엔진보다 더 수준 높은 최적화를 할 수 있었습니다.

Optimium은 Nadya의 Metaprogramming을 이용해 다양한 방법으로 코드를 생성하여 여러분의 하드웨어에서 가장 빠르게 동작하는 구현을 찾아냅니다. 이렇게 해서 나온 프로그램은 여러분의 컴퓨터 상황에 맞춰 만들어지게 됩니다. 그러면 여러분의 컴퓨터는 마치 기성품 운동화만 신다가 맞춤 제작된 수제 운동화를 신은 마라톤 선수처럼 더 빠르고 효율적으로 프로그램을 실행하게 됩니다. 이것이 Optimium이 하드웨어의 스펙과 종류에 구애받지 않고 우수한 성능을 제공하는 이유입니다.

Metaprogramming을 손쉽게 활용하기 위한 Nadya의 노력

  1. Metaprogramming을 위해 컴파일 시간에 실행되는 코드와 실제로 컴파일이 끝난 후 실행되는 코드 사이에 차이가 거의 존재하지 않습니다. 심지어 Metaprogramming을 통해 생성된 코드를 그대로 다시 컴파일 시간에 실행할 수도 있습니다.

  2. 매우 직관적인 방법으로 프로그램에서의 표현 식을 값처럼 다룰 수 있습니다. 서로 합성을 할 수도 있고, 함수 인자로 넘길 수도 있습니다.

    let expression = !{a + b} // expression 에 a + b 표현식을 넣음
    print(expression) // 'a + b' 출력
    let twoExprs = [expression;expression] // expression 이 담긴 리스트 생성
  3. 표현식 내부에 다른 표현식을 넣어서 보다 직관적으로 구성할 수 있습니다.

    let res = fib 10 // fib는 피보나치 수열을 구하는 함수이며, 10을 넣었으므로 res는 55임
    let expression = !{a + ${res}} // a + (res 의 컴파일 시간 결과물) 로 구성된 표현식을 만듦.만약 res의 결과물이 컴파일 시간에 55로 계산되었다면, a + 55를 만듦
    print(expression) // 'a + 55' 출력

Nadya가 강력한 이유 2: 스마트한 컴파일러 최적화 파이프라인

Nadya가 Metaprogramming을 통하여 코드를 생성하여도 실행이 되기 위해선 컴퓨터가 읽을 수 있는 기계어로 바꾸어야 합니다. 이 과정은 컴파일러가 해줍니다. 하지만 Nadya는 단순하게 1 대 1 매칭하는 단순한 방식으로 코드를 컴파일하지 않습니다. Nadya에서는 아래와 같이 다양한 파이프라인들을 통하여 컴파일이 진행됩니다.

  1. 자동 병렬화

Nadya 컴파일러는 스스로 Loop을 분석해 병렬화해도 문제가 없는지 판단하는 기능이 있습니다.

개발자의 컴퓨터 상황(Multi Core 사용 가능 여부, 전력 이슈 존재 여부 등)이 병렬화가 가능한 환경일 때엔 아래의 코드처럼 “attr[Parallel : true]” 을 주어서 자동 병렬화를 사용할 수 있습니다.

// Matmul implementation in Nadya language
// f32 : 32bit floating point, i32 : 32bit signed integer
fun matmul(mut &c : tensor<f32, 2>, a : tensor<f32, 2>, b : tensor<f32, 2>) -> i32 {
 attr[Parallel : true] // 자동 병렬화 사용 
 for(mIdx from 0 to 8 step 1){
  // ... Implementation
 }
 0 // 성공 시 0 을 Return 
}
  1. 자동 메모리 최적화

컴퓨터는 일반적으로 같거나 가까운 메모리 주소를 반복해서 접근할수록 빠르게 동작합니다. Nadya는 개발자 코드의 메모리 패턴을 분석하여 최대한 비슷한 메모리 주소를 여러 번 접근하도록 최적화합니다.

  1. 자동 벡터화

오늘날 컴퓨터 대부분은 한 번에 여러 번의 연산을 동시에 수행할 수 있습니다. 두 숫자를 더하더라도 1개씩이 아닌 4개, 8개씩 할 수도 있습니다.(이를 Vectorization 이라고 합니다). 원래대로라면 이 기능을 사용하기 위해 전문적인 하드웨어 지식이 필요했습니다. 하지만 걱정하지 마세요. Nadya는 스스로 개발자의 코드를 분석하여 컴퓨터가 여러 번의 연산을 한 번에 할 수 있게 하여 더욱 빠르게 동작하게 합니다.

Nadya를 만나기 전 (feat. 자동 벡터화 미적용)

세상에는 다양한 컴퓨터와 칩이 존재하며, 이들이 각각 상황에 맞게 최적으로 동작하는 조건은 모두 다릅니다. 최적화 방법인 벡터화(Vectorization)를 하거나 반복문을 수정(Loop Unroll)하더라도 이를 어떻게, 얼마나 수행하느냐에 따라 성능이 달라질 수 있습니다. 기존의 추론 엔진들은 이러한 부분들이 고정되어 있으며, 바꾸기가 어렵습니다. 또한, 다양한 최적화 방법들을 개발자가 원하는 환경에서 사용하기 위해서 환경에 맞게 최적화를 진행해야 하는 수고스러움이 발생합니다. 하지만 Nadya를 사용하는 Optimium은 다릅니다. Nadya의 강력함을 설명하기 위해 Transformer, Convolution 등 AI 연산에 가장 많이 사용되는 연산 중 하나인 행렬 곱셈을 통해 예시를 들어 보겠습니다.

💡 <Vectorization>
· For 문을 대체하는 최적화된 Array Expression으로 연산을 수행하며 이는 하드웨어 상의 SIMD(Single Instruction Multiple Data) 동작과 관련이 있습니다.

· Vectorization을 구현하기 위해서는 어셈블리를 직접 작성하거나 built-in(Intrinsic) 함수들을 이용해야 합니다.

// Matmul implementation in Nadya language
// f32 : 32bit floating point, i32 : 32bit signed integer
fun matmul(mut &c : tensor<f32, 2>, a : tensor<f32, 2>, b : tensor<f32, 2>) -> i32 {
 for(mIdx from 0 to 8 step 1){
  for(nIdx from 0 to 8 step 1){
   let mut acc = 0.0f
   for(kIdx from 0 to 8 step 1){
    acc <- acc + a[(mIdx, kIdx)]*b[(kIdx, nIdx)]
   } 
   // 결과 (acc)를 c에 할당
   c[(mIdx, nIdx)] <- acc
  }
 }
 0 // 성공 시 0 을 Return 
}

fun main() -> i32 {
 // 입력, 출력 tensor를 정의
 let a = tensor((8, 8), 1.0f) // Shape 이 8x8인 Tensor a 를 1.0f 로 초기화
 let b = tensor((8, 8), 2.0f) // Shape 이 8x8인 Tensor b 를 2.0f로 초기화
 let mut c = tensor((8, 8), 0.0f) // Shape 이 8x8인 Tensor c 를 0.0f로 초기화
 matmul(&c, a, b)
}

!{
 main() 
}

해당 코드는 아직 Nadya의 최적화를 적용하지 않은 코드입니다. Fun 함수 내부에 있는 코드는 타겟 하드웨어에 맞는 바이너리로 직접 컴파일하며, 곧바로 실행이 가능하게 합니다. 하지만 위 코드는 높은 성능을 내기 어렵습니다. 다양한 이유가 있지만, 주된 이유는 값을 한 번에 1개씩만 연산할 수 있기 때문입니다.

최적화되지 않은 코드를 Arm Cortex-X1 을 타깃으로 컴파일하면 다음과 같은 명령어(Assembly)가 출력됩니다. 가장 안쪽의 반복문에서 곱셈 및 덧셈 연산을 수행하는 부분의 어셈블리 언어입니다. 자세히 보면 숫자를 하나하나 따로 연산하는 것을 알 수 있습니다.

# Main Loop 내부
...
ldr s3, [x11, x22, lsl #2]
fadd s1, s1, s0 # 숫자 1개씩 곱셈
fmul s3, s4, s3 # 숫자 1개씩 곱셈
fadd s1, s1, s2 # 숫자 1개씩 덧셈

이를 이미지로 표현하면 아래와 같습니다. 행렬 C는 행렬 A와 행렬 B를 곱한 결괏값입니다. 지금 상황에서 행렬 B처럼 Vector 값이 고정되어 있으면 결과를 도출할 때 상대적으로 많은 시간이 소요됩니다.

Nadya를 만난 후 (feat. 자동 벡터화 적용)

위에서 언급 드렸듯이 벡터화는 숫자를 하나씩 연산하는 것이 아닌 여러 개로 묶어서 한 번에 연산하는 것을 의미합니다. 벡터화를 적용하면 실행해야 할 명령어의 수가 줄어들어 속도가 향상됩니다. 위에 작성한 코드를 약간 수정하면 원하는 크기로 코드를 벡터화하게 할 수 있습니다. 아래 코드는 Nadya가 개발자가 원하는 만큼 코드를 벡터화하여 최적화하는 예시입니다.

// Vectorized Matmul implementation in Nadya language
// vectorBitWidth : 사용 가능한 최대한의 vector bit width
template</vectorBitWidth/>
attr[ Optimization : { VectorSize : vectorBitWidth }]
fun matmul(mut &c : tensor<f32, 2>, a : tensor<f32, 2>, b : tensor<f32, 2>) -> i32 {
  // vector bitwidth에 들어가는 element의 개수
  // 컴파일 타임에 계산됩니다
    let elems = ${vectorBitWidth} / 32 
    for(mIdx from 0 to 8 step 1){
  for(nIdx from 0 to 8 step elems){
   let mut acc = tensor((elems,), 0.0f)
   for(kIdx from 0 to 8 step 1){
    // 한 번에 elems 만큼의 숫자를 계산
    acc <- acc + a[(mIdx, kIdx:kIdx+1:1)]*b[(kIdx, nIdx:nIdx+elems:1)]
   } 
   // 결과 (acc)를 c에 할당
   c[(mIdx, nIdx:nIdx+elems:1)] <- acc
  }
 }
 0 // 성공 시 0 을 return 
}

template</vectorBitWidth/>
fun main() -> i32 {
 let a = tensor((8, 8), 1.0f) // shape 이 8x8인 tensor a 를 1.0f 로 초기화
 let b = tensor((8, 8), 2.0f) // shape 이 8x8인 tensor b 를 2.0f로 초기화
 let mut c = tensor((8, 8), 0.0f) // shape 이 8x8인 tensor c 를 0.0f로 초기화
 matmul</vectorBitWidth/>(&c, a, b)
}

// 사용할 vector의 size
let vectorBitWidth = 128

!{ 
 // 입력, 출력 tensor를 정의
 // main함수에 vectorBitWidth parameter를 전달하기 위해 별도로 호출
 main</vectorBitWidth/>()
}

위 코드에서는 VectorBitWidth (벡터의 비트 크기)의 값에 따라 프로그램이 사용할 벡터의 비트 크기를 정할 수 있습니다. 여기서 Nadya 코드는 VectorBitWidth의 크기에 따라 Vector Size가 달라지도록 코드를 생성합니다. 다른 부분을 전혀 바꾸지 않더라도, VectorBitWidth의 값만 조정하면, 손쉽게 프로그램이 사용하는 VectorBitWidth를 정할 수 있습니다. 위에 이미지와 다르게 행렬 B의 벡터 값이 이전보다 더 넓어졌습니다. 이를 통하여 더 빠른 연산을 지원할 수 있습니다.

이처럼 Nadya에서는 프로세서의 종류에 상관없이 희망하는 Vector의 크기를 정한다면, 컴파일러가 알아서 최적화를 진행합니다. Raspberry Pi, Amazon Graviton과 같은 Arm 기반 프로세서에서는 주로 128bit (이론상 최대는 2,048bit), Intel 및 AMD 기반 칩에서는 주로 256비트 혹은 512비트가 사용되는 것처럼 개발자는 실제 프로세서의 종류를 고려해야 합니다. 하지만, Optimium의 경우 VectorBitWidth의 값을 여러 개의 후보를 두고 실험하여 최적의 성능을 내는 값을 찾을 수 있기에 이 문제를 해결할 수 있습니다.

위 코드를 Arm Cortex-X1 을 타깃으로 컴파일하면 다음과 같은 명령어(Assembly)가 출력됩니다.

... 
ldr q2, [x16]
fmla.4s v1, v0, v3[0]
ldp s0, s3, [x0, #-8]
fmla.4s v1, v2, v0[0]

이전과 다르게 ‘fmla’ 연산이 추가하여, 한 번에 4개의 숫자를 계산할 수 있게 최적화를 적용하였습니다.

  • ‘fmla’연산은 곱셈과 덧셈을 한 번에 수행합니다.

  • ‘.4s’는 숫자 4개를 한 번에 연산한다는 의미입니다.

이렇게 만들 경우, 연산 속도가 훨씬 빨라지게 됩니다. Nadya의 코드 생성 기능을 통해, 각 CPU, 아키텍처에 따라 새롭게 코드를 작성할 필요 없이, 값 1개를 조정하는 것만으로 다양한 하드웨어에 맞게 컴파일할 수 있습니다. Optimium의 Layer는 이렇게 Nadya를 이용하여 구현되어 있습니다. 위 예시에서는 간단한 코드 벡터화 하나만 수행했지만, 실제 Optimium에서 구현되는 연산은 매우 다양한 최적화 기법과 고도화된 코드 생성 기능을 사용합니다.

Optimium은 위와 같이 프로그램 코드 생성에 영향을 미치는 값들을 조정하며 실제 코드를 실행하고자 하는 Target에서 가장 빠르게 동작하는 코드를 찾게 됩니다. C/C++ 혹은 어셈블리로 짜인 기존 추론 엔진처럼 모든 하드웨어 종류마다 다르게 만들 필요가 없습니다. Optimium팀은 Nadya를 이용하여 각 하드웨어 타깃에 맞는 고도의 최적화를 진행하였고, Nadya로 작성된 연산은 지원하는 모든 하드웨어 타깃에서 단 1개의 구현만으로 동작합니다. 이를 통해 Optimium은 기존의 추론 엔진보다 압도적인 성능을 제공합니다.

Optimium powered by Nadya!

Nadya는 현재 CPU만 지원하고 있지만, 향후 GPU (CUDA 및 Vulkan 등)를 비롯한 새로운 하드웨어를 추가 지원할 계획을 하고 있습니다. 또한, 이번 베타 테스트에서는 Nadya를 직접 사용하실 수 없지만 향후 추가적인 검증 및 안정화 후 별도로 공개할 계획도 검토 중이니, 앞으로도 Nadya에 많은 관심 부탁드립니다. 🙂

Nadya를 기반으로 저희 팀이 직접 개발한 Optimium은 현재 베타 테스트 진행 중이며, 다양한 하드웨어 환경에 대하여 기존 추론 최적화 엔진들 대비 우수한 성능을 보여 다양한 업체들의 관심을 받고 있습니다. 아래와 같이 이미 가장 많이 쓰이고 있는 TensorFlow Lite XNNPACK 대비 우월한 추론 속도를 도출하고 있으니, 직접 경험해보고 싶으신 분들은 아래 베타 신청 링크를 통해 언제든지 연락 부탁드립니다.

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

Life is too short, you need Optimium

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