Company

Resources

Company

Resources

Product

Optimium 탐구(5) — Introduction to Nadya

Optimium 탐구(5) — Introduction to Nadya

Optimium 탐구(5) — Introduction to Nadya

이전에 Optimium팀의 일원으로서 Nadya의 최적화 기능에 대해 간단하게 소개시켜 드린 적이 있는데요, 오늘은 Nadya 그 자체에 조금 더 포커스를 맞추어 여러분들께 소개드리고자 합니다. Nadya는 현재는 Optimium 내부 연산 구현에 사용되고 있지만, Nadya는 Optimium뿐만 아니라 어디서든 사용될 수 있는 고성능 컴퓨팅 맞춤 프로그래밍 언어가 되고자 합니다.

Jaewoo Kim

May 16, 2024

안녕하세요? ENERZAi에서 Nadya를 개발하고 있는 김재우라고 합니다. 이전에 Optimium팀의 일원으로서 Nadya의 최적화 기능에 대해 간단하게 소개시켜 드린 적이 있는데요, 오늘은 Nadya 그 자체에 조금 더 포커스를 맞추어 여러분들께 소개드리고자 합니다. Nadya는 현재는 Optimium 내부 연산 구현에 사용되고 있지만, Nadya는 Optimium뿐만 아니라 어디서든 사용될 수 있는 고성능 컴퓨팅 맞춤 프로그래밍 언어가 되고자 합니다. 그리고 Nadya 개발팀은 고성능 컴퓨팅의 무궁무진한 가능성을 녹여내고자 밤낮없이 고민하며 개발에 몰두하고 있습니다. 여러분들께 Nadya에 관해 소개드릴 수 있어 정말 기쁘게 생각합니다.

Nadya가 무엇인가요?

Nadya는 누구나 고성능 컴퓨팅 (HPC) 소프트웨어를 쉽고 빠르며 안전하게 개발하는 것을 목표로 개발된 프로그래밍 언어입니다. 현 시점에서는 Optimium의 내부 연산들을 구현하는데 사용되고 있으며, 지속적인 연구개발을 통해 발전을 거듭하고 있습니다.

Nadya를 만들게 된 배경

Optimium을 처음 개발할 때, 내부의 연산 커널들을 C 혹은 Rust와 같은 컴파일 언어를 통해 구현하고자 했지만, 다음과 같은 문제들을 마주하게 되었습니다.

  1. 다른 솔루션보다 빠르게 만드려면 한 번 구현하는데 시간이 오래 걸리고 신경을 굉장히 많이 써야 한다.

  2. 힘들게 구현을 해도 다른 곳에서 실행하면 성능이 원하는 만큼 나오지 않는다.

Optimium의 성능을 범용적으로 좋게 만드려면 결국 수동적으로 수많은 연산들을 모든 하드웨어에 대해서 구현해야 했는데, 이는 인력과 자원이 한정된 저희 팀으로서는 채택하기 어려운 방법이었습니다. 따라서 저희는 타깃 맞춤 코드 생성을 통해 연산들을 만들기로 했지요.

그렇다면 코드 생성은 어떻게 할 수 있을까요? 가장 쉽게 떠오르는건 C 코드를 직접 생성하는 방법인데요. 정량화된 패턴을 만들어서 code generation을 수행하면 됩니다. 기존 솔루션들이 많이 사용하는 방법이지만, 저희가 원하는 수준의 성능을 내기에 적합한 방법은 아니었습니다.

따라서 저희는 새로운 프로그래밍 언어를 만들기로 했습니다. Code generation을 언어 레벨에서 지원하며, 강력한 컴파일 시간 최적화를 지원하고, 마지막으로 함수형 패러다임 지원을 통해 연산 작업들을 쉽게 구조화할 수 있도록 하는 언어를 만들자는 목표를 가지고 시작했지요. 그렇게 열심히 달려온 결과물이 현재의 Nadya 입니다.

디자인 방향성

Nadya를 만들게 된 계기는 Optimium이지만, 저희 팀은 고성능 프로그래밍이 필요한 모든 Application에 사용 가능한 범용적인 언어를 만들겠다는 목표 의식을 가지고 Nadya를 제작하였습니다. 새로운 프로그래밍 언어는 Optimium에 사용하기 좋으면서, 범용성이 있고, 사람들이 사용하기 쉬워야 했지요. 정리하자면 다음과 같습니다.

  1. 쉽고 빠르게 HPC(고성능 컴퓨팅)를 위한 코드를 작성할 수 있음 (개선 중)

  2. 완전하고 독립적으로 작동 가능하여 어느 곳에서든 쓰일 수 있음 (지원 가능)

  3. 강력한 컴파일러 최적화를 지원하여 프로그래머 개입 없이 코드 최적화 지원. (지원 가능, 개선 중)

  4. 메타프로그래밍 및 코드 생성 기능 내장 (지원 가능, 개선 중)

  5. 메모리 안정성을 보장 (구현 중)

  6. 타입 안정성을 보장 (구현 중)

  7. 함수형 패러다임 지원으로 연산 과정을 단순화 (부분적 지원 가능, 개선 중)

조금 도전적인 목표이긴 하지만 Nadya를 개발하는 엔지니어들은 도전을 선택했습니다. 이미 일부 목표는 달성하였고, 지금 이 순간에도 모든 목표를 달성하기 위한 치열한 연구개발이 이루어지고 있습니다.

그럼, Nadya의 핵심 기능들을 하나씩 살펴볼까요?

(일부 기능들은 개발 중이므로 정확한 syntax는 정식 공개 이전까지 변동 가능하다는 점을 참고해 주시기 바랍니다)

강력한 컴파일러 최적화

Nadya 컴파일러는 사용자의 코드 패턴 분석 및 최적화를 지원합니다. 기존 컴파일러에 -O3 등의 옵션을 붙이는 것을 넘어서, 언어 자체적으로도 최적화 기능을 갖추고 있습니다.

그렇다면 Nadya에 탑재된 컴파일러 최적화 기능 중 일부를 소개해드리도록 하겠습니다.

  1. 코드 패턴 분석 intrinsic lowering

  • 사용자가 코드를 짜면, Nadya compiler는 이를 Nadya 만을 위해 만들어진 중간 단계 언어 (IR)로 변환합니다. 이 중간 단계 언어 상에서 패턴을 분석하고, 해당 패턴을 가속화해주는 하드웨어의 명령어 셋이 존재한다면 이를 해당 명령어로 치환합니다.

  • 예를 들어, ARM에는 두 벡터를 곱한 다음 2를 곱하고, high bit를 반환하는 VQDMULHQ라는 명령어가 있습니다. Nadya에서 다음과 같은 코드를 쓰면 컴파일러가 자동으로 패턴 분석을 통해 해당 명령어를 사용하도록 해줄 수 있는데요. 이를 통해 다른 언어라면 직접 작성하거나, for문을 돌면서 복잡하게 구현해야 하는 로직을 단순화시켜 비약적으로 성능을 향상시킬 수 있습니다.

let tensorA = tensor((8,), dataA) 
let tensorB = tensor((8,), dataB) 
let result = (tensorA * tensorB * 2) >> 16

2. Cache 최적화

  • Nadya 컴파일러는 cache locality를 극대화하기 위해 사용자의 메모리 할당 로직을 분석합니다. 일반적으로 캐시는 같은 곳에 반복적으로 접근하거나, 연속된 주소에 접근하는 경우에 성능이 잘 나오게 되는데, 이를 위해 Nadya는 프로그램이 비슷한 메모리에 반복해서 접근하도록 코드를 수정할 수 있습니다. 물론 코드의 correctness를 해치지 않는 선에서요. 1)에서의 예시를 다시 한 번 살펴보겠습니다.

let tensorA = tensor((8,), dataA) 
let tensorB = tensor((8,), dataB) 
let result = (tensorA * tensorB * 2) >> 16  

// tensorA 에 접근 
print(tensorA[(1, )])  

// tensorB는 접근하지 않음. 
// 그렇다면, data의 크기가 같은 result를 tensorB의 메모리로 사용할 수 있습니다. 

위 예시에서 tensorA는 다시 사용되지만 tensorB는 이 이후로 사용되지 않고, result와 tensorB가 요구하는 메모리의 크기가 같다는 것을 알 수 있습니다. 그렇다면, Nadya 컴파일러는 result와 tensorB가 같은 메모리를 사용하도록 하여 사용되는 메모리 양을 줄임과 동시에, 코드를 cache friendly 하게 바꿀 수 있습니다.

3. 자동 병렬화

  • Nadya 컴파일러는 loop에 destructive update(순차적 업데이트)가 없을 경우 자동으로 병렬화 해주는 기능을 내장하고 있습니다. 아래와 같이 작성할 경우, Nadya컴파일러가 loop을 병렬화 할 수 있다고 판단하면, loop를 병렬화합니다.

attr[Parallel] 
for(idx from 0 to 10 step 1){
  // Implementation
}

4. Stack forwarding & Mem2Reg

  • Nadya compiler는 불필요한 heap allocation을 제거하는 기능을 보유하고 있습니다. 물론 memory management도 직접 관리하지요. Stack에 할당하기 쉬운 구조라면 자동으로 stack에 주소를 할당하며, stack에 할당하더라도 이를 register로 옮길 수 있다면 자동으로 옮겨주는 기능도 지원합니다.

  • 이러한 기능들을 지원하는 이유는 메모리를 stack에 할당할 경우 훨씬 빠르게 메모리를 할당할 수 있으며, memory leak의 위험성이 없기 때문입니다. Stack 메모리는 function이 종료될 때 자동으로 해제되며, 할당 역시 내부적으로 프로세서의 스택 포인터를 업데이트 하는 것으로 마무리되기 때문에 매우 빠릅니다.

  • 만약 한발 더 나아가 register(레지스터) 에 데이터를 넣을 수 있다면, 프로세서는 메모리에 접근할 필요가 없기 때문에 훨씬 빠른 속도로 동작할 수 있습니다. 다만, 과도하게 register를 사용할 경우 필요한 register가 부족하게 되어 오히려 성능이 저하될 수 있으므로 적당한 수준으로 조정해야 합니다.

  • Nadya 컴파일러는 이러한 과정들을 스스로 진행해, 원래는 프로그래머가 아키텍처에 맞게 신경써서 작성해야 했던 부분들을 자동으로 수행 가능합니다.

이외에도 다양한 최적화 기법들이 있지만, 다음 기회에 소개하도록 하겠습니다.

간소화된 데이터 연산

Nadya에는 ‘tensor’라는 별도의 타입이 존재합니다. Tensor 타입은 행렬과 같은 데이터를 쉽게 다루기 위해 제작되었으며 shape와 data type을 통해 정의할 수 있습니다. Tensor는 컴파일러 레벨에서의 최적화를 지원하며, 프로그래머는 Tensor를 통해 수월하게 데이터를 관리할 수 있습니다.

Tensor는 다음과 같이 정의하고 사용할 수 있습니다.

let a = tensor((2,3), {1,0f,2,0f,3,0f,4.0f,5.0f,6.0f}) // 32bit floating point tensor shaped (2,3)
let b = tensor((3,), {1.0f, 2.0f, 3.0f}) // 32bit floating point tensor shaped (3,)
// Tensor arithmetics (automatically broadcasted)
print(a + b) // tensor((2,3), {2.0f, 4.0f, 6.0f, 5.0f, 7.0f, 9.0f})

다음과 같이 tensor를 reference하여 사용할 수 있으며, 이러한 경우에도 컴파일러의 최적화 기능은 문제 없이 동작합니다.

let mut a = tensor((2,3), {1,0f,2,0f,3,0f,4.0f,5.0f,6.0f}) // 32bit floating point tensor shaped (2,3)
let mut refA = &a
refA[0, 0] <- 3.0f // Modifies both a & refA
print(a) // prints tensor((2,3), {3,0f,2,0f,3,0f,4.0f,5.0f,6.0f})

함수형 패러다임

함수형 패러다임은 저희가 많은 공을 들이고 있는 부분들 중 하나입니다. 함수형 패러다임을 유지하면서 성능을 높이는 것은 매우 어려운 Task이지만, 저희가 함수형 패러다임에 투자를 하는 것은 프로그래머에게 많은 가치를 가져다 준다고 생각하기 때문입니다. 타입 안정성을 보장하기 쉬워지며, 더욱 쉬운 방법으로 프로그램을 정의할 수 있지요. 함수형 프로그래밍은 다음과 같이 정의할 수 있습니다.

  • ‘순수 함수’를 정의할 수 있습니다. Side effect가 없기 때문에 프로그래머가 자신의 프로그램이 어떻게 동작할지 쉽게 모델링할 수 있습니다.

// 순수 함수
let purFunction a b = a * b + 10  

// 재귀 순수 함수
let rec recursivePureFunc input =   
  // pattern matching  
  match input with  
  | 0 -> 0  
  | _ -> input + recursivePureFunc (input - 1)
  • 순수 함수를 정의할 수 있다면 병렬 처리를 통한 고성능 연산에 쉽게 활용될 수 있습니다. 병렬 프로그래밍에서는 side effect(부수 효과)가 가장 큰 걸림돌인데, 언어 차원에서 side effect가 발생하지 않는 다는 것을 보장하기 때문이지요.

  • 함수를 ‘값’처럼 취급할 수 있습니다. 얼핏 들으면 C++, Rust의 람다 표현식을 떠올릴 수 있지만, 이들 언어가 외부 변수를 capture하게 될 경우 서로 다른 타입이 되는 반면, Nadya에서는 함수의 인자와 return type이 같다면 같은 타입으로 취급할 수 있습니다. 따라서 람다 표현식의 타입을 통일하기 위한 C++ 의 type erasure와 같은 개념이 불필요해지며, 다음과 같은 접근이 가능합니다.

let outSide = 2 
let funcA a b = a + b 
let funcB c d = c + d + outSide  

// funcA와 funcB가 같은 타입이기 때문에 같은 data structure에 담을 수 있습니다. 
let closures = [funcA; funcB]

메모리 안정성

Nadya는 언어 차원에서 메모리 안정성을 해치는 코드를 짜는 것을 최소화하도록 디자인되었습니다. Nadya는 변수에 소유권 개념을 도입하여 이를 관리합니다. Nadya는 데이터 객체의 소유권이 어떻게 이동하는지 추적하여 메모리가 잘못 사용되는 것을 최대한 방지합니다. 이 기능은 디자인 후 현재 구현 중에 있으며 앞으로 발전시켜나가고자 합니다.

  1. 소유권 (Ownership)

  • 데이터 객체는 참조(borrow, reference), 복사(copy), 이동(move) 될 수 있으며 한 개의 데이터에 대해서는 최대 1개의 변수(variable) 만 소유권을 가질 수 있습니다.

  • 데이터를 ‘소유’ 한다는 것은 데이터를 소유한 변수가 데이터를 참조(borrow) 하는 변수에 비해 우선 순위를 가지며, 데이터의 생성과 해제에 대한 책임이 있음을 의미합니다.

let owned = DataType(10) // owned 는 DataType(10)을 소유합니다. 
// 나중에 scope가 종료되어 onwed가 해제될 때, DataType(10) 가 가진 내부 데이터 구조도 함께 할당 해제됩니다. 

  1. 소유권을 가지지 않은 변수가 데이터를 읽거나 수정하려면, 소유권을 가진 binding으로부터 데이터를 참조(borrow) 해야 합니다.

let owned = DataType(10) 
let borrow = &owned // borrow가 owned로부터 data를 참조로써 빌립니다. 
let newValue = (@borrow).addTo(11) // 참조를 통해 borrow에 접근할 수 있습니다. 

  • 이를 통해 데이터를 참조하는 변수는 데이터에 접근할 수 있지만, 그 데이터를 할당 해제하거나 임의로 소유권을 이동시킬 수는 없습니다. 이렇게 하는 이유는 데이터를 소유하고 있는 변수가 투명하게 데이터를 생성하고 해제하도록 하기 위함이며, 이는 메모리를 안전하게 생성하고 해제하는데 매우 중요합니다.

  1. 소유권을 가진 변수는 이를 참조하는 binding보다 반드시 오래 살아남아야 합니다.

  • Nadya는 이를 컴파일 타임 분석을 통해 분석하고, 이것이 보장되지 않는 경우 프로그래머에게 알림으로써 메모리 참조 에러를 미리 방지할 수 있습니다. 이러한 기능을 구현한 이유는 참조를 하는 데이터 변수가 소유권을 가진 변수보다 오래 살아남았을 때, 참조 변수가 데이터에 접근하는 경우, 원본 데이터가 해제되었으므로 에러가 발생할 수 있기 때문입니다.

let owned = DataType(10) 
let borrow = &owned // borrow가 owned로부터 data를 참조로써 빌립니다. 
let moved_borrow = move(borrow) // 참조된 데이터에 대한 소유권을 moved_borrow로 이동합니다.  

print("{}", @borrow) // 에러! borrow는 moved_borrow로 이동되어 사용될 수 없습니다. 
print("{}", @moved_borrow) // DataType(10) 을 출력합니다.

내장된 코드 생성기

Nadya가 다른 언어들과 구별되는 가장 큰 특징 중 하나는 언어 차원에서 코드 생성 기능을 제공한다는 것입니다. 이를 통해 프로그래머들은 Nadya 언어로 새로운 Nadya 코드를 정의하고, 생성할 수 있습니다. 보통의 경우 이는 중요한 기능이 아니겠지만, 고성능 프로그래밍 언어를 목표로 하는 Nadya로서는 매우 중요한 기능들 중 하나입니다. Nadya는 이 기능을 통해 Optimium이 사용자 환경에 따라서 실행될 코드를 다르게 생성하여 고도의 최적화를 제공합니다.

예를 들어 input의 크기에 따라 어떨 때는 일반적인 행렬 연산, 크기가 커지면 tiling된 행렬 연산을 수행하고자 하는 case를 가정해봅시다. 이러한 경우 아래와 같이 input의 크기를 threshold와 비교하여 사용할 수 있습니다.

그냥 실시간으로 입력을 받아서 분기를 처리하면 안되나? 와 같은 의문이 드실 수도 있는데요. 위와 같은 과정을 통해 code를 생성해두면, 실제 target에 들어가는 코드의 사이즈를 줄일 수 있을 뿐만 아니라, 알고리즘이나 코드의 세부적인 부분을 선택하는데 소요되는 시간을 절약할 수 있습니다.

// Generate code creating default matmul, or tiled matmul depending on input size
// expression stores generated code by each function

let mut expression = !{0}

if(inputSize < threshold) {
    expression <- generateDefaultMatmul();
} else {
    expression <- generateTiledConv();
}

// Use generated code for building gemm algorithm
// !{ code } represents generated code
//! ${ code } represents instantiation of result into the generated code.
let gemm = !{ alpha * ${expression} } + beta * bias}
// ...

앞으로의 계획

Nadya는 전문적인 지식이 없는 분들도 빠르게 안전한 고성능 프로그램을 작성할 수 있도록 하는 것이 가장 큰 목표입니다. Nadya 개발진은 지금도 그 목표를 향해 힘차게 나아가고 있습니다. Nadya에 추가 예정인 기능들은 다음과 같습니다.

  1. GPU(CUDA, Vulkan) 지원

  • CUDA나 Vulkan에 관해 자세히 모르더라도, 쉽고 간단하게 GPU 프로그램을 작성할 수 있는 기능을 지원하고자 합니다.

  1. CPU를 위한 matrix extension intrinsic지원

  • 현대 CPU에는 AI 연산을 가속화하기 위해 행렬 곱셈 유닛이 들어가는 경우가 많아지고 있습니다. Nadya에서도 이 기능을 지원하고자 합니다.

  1. 다양한 병렬 프로그래밍 기법 지원

  • 단순히 loop을 병렬화하는 것 이외에도 다양한 병렬화 기법이 존재하는데, Nadya에서는 이를 빌트인으로 지원할 수 있도록 기능을 연구 중에 있습니다.

  1. 더욱 강력한 컴파일러 최적화 지원

  • 현재도 많은 기능들이 있지만 더욱 다양한 기법들이 연구 중에 있으며, 이를 통해 Nadya 컴파일러를 더욱 강력하게 만들고자 합니다.

이처럼 Nadya는 다양한 방법을 통해 누구나 쉽고 빠르게 고성능 프로그램을 작성할 수 있도록 개발자 분들에게 가까이 다가가고자 합니다. Nadya는 지금 현재도 활발히 개발이 진행 중이며, 앞으로도 더 많은 개선 과정을 거쳐 나갈 계획입니다. 저희의 여정을 독자 여러분들께서 관심 있게 지켜봐주시면 감사하겠습니다.

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