Company

Resources

Company

Resources

Technology

ABI 심층 분석: Lessons learned from Nadya

ABI 심층 분석: Lessons learned from Nadya

ABI 심층 분석: Lessons learned from Nadya

안녕하세요? ENERZAi에서 Nadya(에너자이가 자체적으로 개발한 메타프로그래밍 언어로, 에너자이의 AI 추론 최적화 엔진 Optimium에서 핵심적인 역할을 수행합니다)를 개발하고 있는 신진환이라고 합니다. 이번 게시물에서는 저희 팀에서 Nadya를 개발하면서 겪었던 문제와 해당 문제를 어떻게 해결했는지에 대하여 간단히 공유드리려고 합니다.

Jinwhan Shin

February 26, 2025

안녕하세요? ENERZAi에서 Nadya(에너자이가 자체적으로 개발한 메타프로그래밍 언어로, 에너자이의 AI 추론 최적화 엔진 Optimium에서 핵심적인 역할을 수행합니다)를 개발하고 있는 신진환이라고 합니다. 이번 게시물에서는 저희 팀에서 Nadya를 개발하면서 겪었던 문제와 해당 문제를 어떻게 해결했는지에 대하여 간단히 공유드리려고 합니다.

Nadya를 테스트 하는 과정에서 aggregate type(struct type)만 사용하면 Nadya runtime library에서 segmentation fault가 발생하는 이슈가 있었는데요. 해당 code는 IR level에서는 문제가 없음에도 불구하고, machine code로 컴파일 되어 실행될 때 memory address가 corrupt되는 문제가 지속적으로 관찰되었습니다. 해당 이슈를 해결하기 위해 Debugger를 통해 instruction 단위로 추적해본 결과, C++ 쪽의 함수의 인자와 반환값이 값(value)이 아닌 pointer로 전달되는 것을 확인하게 되었습니다. 왜 value로 전달한 값을 C++에서는 pointer로 받는지에 대한 원인을 파악해본 결과, 이는 Application Binary Interface(ABI)에서 정의한 규칙 때문에 발생한 이슈라는 사실을 확인할 수 있었습니다.

이번 글에서는 위의 문제를 해결하는 과정에서 알게 된 내용들을 공유드리려고 하는데요. 구체적으로는 ABI가 무엇인지, LLVM에서는 이를 어떻게 handling하는지에 대해 말씀드리려고 합니다.

What is ABI?

하드웨어와 밀접하게 작업하시는 개발자 분들이 아니라면, Application Binary Interface라는 개념이 비교적 낯설게 다가오는 분들도 많으실 텐데요. 우선 ABI가 무엇인지 간략히 알아보도록 하겠습니다. 대부분의 개발자 분들은 작업하실 때 Application Programming Interface(API)를 많이 사용하실 텐데요. API란 라이브러리 설계자가 본인의 라이브러리에서 어떤 정보를 어떤 규칙으로 전달하고, 특정 작업을 수행하기 위해 어떤 class 혹은 function을 사용해야 하는지 등의 규칙(interface)을 정의 한 것입니다.

마찬가지로, 컴파일러 개발자나 하드웨어 개발자가 int, long 같은 타입의 크기는 어떤지, 구조체로 표현한 자료를 어떻게 메모리에 배치할지, 함수 안에서는 어떤 register를 사용할 수 있고 인자를 전달할 때나 반환값을 받을 때에는 어떤 register를 사용해야 하는지 등의 규칙을 정의 한 것을 Application Binary Interface(ABI)라고 합니다.

따라서, ABI는 사용하는 CPU의 명령어 세트(Instruction Set Architecture, ISA), OS 혹은 컴파일러의 영향을 받게 되는데요. 과거에는 컴파일러에 따라 ABI가 큰 폭으로 변화하는 경우가 많았지만, 오늘날에는 컴파일러에 따른 ABI의 차이가 상당 부분 줄어든 편입니다.

How ABI is implemented in LLVM and Clang?

LLVM은 컴파일러 infrastructure로서 다양한 target architecture와 OS를 다룰 수 있어야 하므로, 하드웨어 및 OS마다 다른 ABI에 대해 대응할 수 있어야 하는데요. LLVM이 어떻게 다양한 ABI를 처리하는 지 알아보도록 하겠습니다.

이 글은 LLVM (https://github.com/llvm/llvm-project)의 llvmorg-17.0.6 tag (commit id 6009708b4367171ccdbf4b5905cb6a803753fe18)를 기준으로 작성된 글입니다. 코드의 내용이 방대하기 때문에, 코드를 직접 삽입하는 대신 각주로 file의 위치와 line을 같이 기입하였습니다.

LLVM에서는 target architecture 및 OS에 따라 바뀌는 정보들(endianness, alignment, primitive types 크기 등)을 기술하기 위한 목적으로 llvm::DataLayout¹ 이라는 자료 구조를 정의하고 있습니다.

llvm::DataLayout은 위에서 언급한 정보들을 기술하기 위해 -을 기준으로 나누어지는 문자열로 표현되며, 나타낼 수 있는 값들은 아래와 같습니다.

  • e: target architecture가 little endian임을 나타냅니다.

  • E: target architecture가 big endian임을 in나타냅니다.

  • iN: Nbit integer의 ABI alignment, preferred alignment를 나타냅니다. e.g) i32:32:32

  • fN: N bit floating point의 ABI alignment, preferred alignment를 나타냅니다. e.g) f64:64:64

  • vN: N bit vector의 ABI alignment, preferred alignment를 나타냅니다. e.g) v256:256:256

  • p: target architecture의 pointer size, alignment, address space (RAM/ROM/MMIO 혹은 GPU 등 memory 영역이 나누어진 환경에서 pointer가 어느 memory space를 가리키고 있는지를 구분하기 위해 임의로 부여된 숫자)를 나타냅니다. e.g) p0:64:64:64

  • n: target architecture가 지원하는 native integer size를 나타냅니다. e.g) 32bit 및 64bit register를 지원하는 경우: n32:64

  • S: Stack alignment를 나타냅니다. e.g) S256

  • F: Function pointer의 alignment를 나타냅니다. i prefix가 붙은 경우 function code의 alignment와는 독립(code까지 이 aligment에 align될 필요 없음)임을 나타내고, n prefix가 붙은 경우 function code도 이 alignment에 align되어야 함을 나타냅니다.

  • P: Function (code)가 저장되는 memory의 address space를 나타냅니다.

  • A: Stack memory의 address space를 나타냅니다.

  • G: Global (data) memory의 address space를 나타냅니다.

  • m: name mangling rule을 나타냅니다. Binary format 혹은 architecture에 맞는 mangling rule을 적용합니다.

  • e: ELF - Linux 등 UNIX 계열 OS에서 사용하는 binary format.

  • l: GOFF - IBM의 z/OS에서 사용하는 binary format.

  • o: Mach-O - macOS, iOS 등 Apple OS에서 사용하는 binary format

  • m: MIPS - MIPS architecture.

  • w: WinCOFF - Windows에서 사용하는 binary format.

  • x: WinCOFF-X86 - Windows에서 사용하는 binary format. X86 특화 버전.

  • a: XCOFF - IBM의 AIX OS에서 사용하는 binary format.

// Data layout for macOS AMD64 (x86-64)
e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128

// Data layout for Windows ARM64
e-m:w-p:64:64-i32:32-i64:64-i128:128-n32:64-S128

// Data layout for Hexagon
e-m:e-p:32:32:32-a:0-n16:32-i64:64:64-i32:32:32-i16:16:16-i1:8:8-f32:32:32-f64:64:64-v32:32:32-v64:64:64-v512:512:512-v1024:1024:1024-v2048:2048:2048

LLVM 및 Clang은 llvm::DataLayout 에 정의된 위와 같은 문자열 정보를 활용하여, target architecture 및 OS에서 지원하는 타입의 크기나 alignment 등의 정보를 사전에 계산합니다. 이후 해당 정보를 바탕으로 실제 struct의 size 및 member variables의 alignment를 계산하거나, call stack alignment, machine code alignment 등의 작업을 수행하게 되는데요. 문자열은 llvm/lib/Target/<arch>/<arch>TargetMachine.cpp 파일들에서 사전에 정의하거나, llvm::Triple을 토대로 조합해 제공합니다. 필요에 따라 사용자가 직접 문자열을 제공할 수도 있지만, LLVM에서 제공하는 data layout을 사용한다면 llvm::TargetMachine class로부터 가져올 수 있으며, 해당 작업을 수행하기 위한 코드는 아래와 같습니다.

std::string triple = "..."; // e.g) aarch64-linux-gnu, arm64-apple-darwin, x86_64-pc-windows-msvc
std::string error;
const llvm::Target *target = llvm::TargetRegistry::lookupTarget(triple, error);
if (!target) { /* do some error handling */ }

// cpu, featureStr, options and RM can be empty value; It does not affect creating llvm::DataLayout.
llvm::TargetMachine *tm = target->createTargetMachine(triple,
                                                      /*cpu=*/"",
                                                      /*featureStr=*/"",
                                                      /*options=*/llvm::TargetOptions(),
                                                      /*RM=*/std::nullopt);
llvm::DataLayout layout = tm->createDataLayout();

How Clang handles calling convention

LLVM에서 ABI에 부합하는 code를 생성하기 위해 여러가지 장치를 준비해 두었지만, 모든 것을 LLVM에서 처리해주지 않습니다. 특히 calling convention의 경우, LLVM은 몇 번째 인자까지 register로 전달되고, 나머지는 stack으로 전달된다 같은 기본적인 사항에 대해서만 처리를 해주고 어떤 인자가 직접 전달되고 간접적으로 전달되는지에 대해서는 Clang 같은 front-end에서 처리하고 있습니다². 해당 작업을 수행하기 위해 Clang에서는 ABIInfo 라는 class를 정의하고, target architecture 및 OS별 ABI 정보를 clang/lib/CodeGen/Targets 폴더에 저장하여 target 환경의 ABI에 부합하는 코드를 생성하고 있습니다.

함수의 인자를 어떻게 전달하는지는 ABIInfo::computeInfo(...) member function³ 를 통해 계산하게 되는데요. 이 method는 virtual member function으로, clang/lib/CodeGen/Targets 폴더 안 target architecture 별 파일들에서 세부 구현을 제공하고 있습니다. 세부적인 내용은 target architecture별, OS별로 상이하므로 이 글에서 다루기에는 너무 광범위하여 모두 다루기는 어렵지만, 대략 아래와 같은 규칙들에 의거하여 함수의 인자가 register를 통해 전달될 지, stack을 통해 value로 전달될 지, register로 전달되나 참조로서 포인터만 전달될 지 결정됩니다.

  • ABI에서 정의하는 register로 전달할 수 있는 type의 크기가 일정 threshold 이하인가?

  • C++에서 정의하는 trivial class인가?

  • 함수 인자 전달에 사용할 수 있는 register들을 전부 소모했는가?

위와 같은 정보를 토대로 LLVM이 calling convention을 따르는 machine code를 생성할 수 있도록 Clang에서 IR code를 생성합니다.

먼저 function declaration을 생성하는 부분을 보면⁴, getTypes().arrangeGlobalDeclaration() 을 통해 조사한 argument 및 return 에 대한 정보를 가져오고, getTypes().GetFunctionType()⁵ member function을 통해 llvm::FunctionType을 만들게 됩니다. CodeGenTypes::GenFunctionType(...) member function 에서는 인자로 전달되는 CGFunctionInfo의 정보를 기반으로 argument가 직접 전달되는 경우 (ABIArgInfo::Direct, ABIArgInfo::Extend) 원래 argument type에 대응되는 llvm::Type이 전달하도록, 간접적으로 전달되야 하는 경우 (ABIArgInfo::Indirect, ABIArgInfo::IndirectAliased)에는 pointer type으로 전달하도록 function signature를 변경해 llvm::FunctionType을 생성 합니다. 또한 return value가 struct 등 크기가 큰 value인 경우 pointer를 통해 간접적으로 return하게 되는데, 이를 위해 암묵적인 function parameter가 추가되는 것도 확인할 수 있습니다⁶.

Function call에 대한 IR code를 생성하는 부분은 CodeGenFunction::EmitCall(...)⁷ 에서 정의하고 있습니다. 먼저 인자로 전달받은 CGFunctionInfo를 토대로 return value의 전달 방식을 확인하고, 간접적으로 전달될 경우 return value를 저장하기 위한 memory를 할당하는 목적으로 AllocaOp를 생성합니다⁸. 인자가 간접적으로 전달되어야 하는 경우 AllocaOp 을 통해 stack memory를 할당하고, 원본 값을 저장한 뒤, 원본 값이 할당된 memory address를 인자로 넘기게 됩니다. 인자가 직접 전달되어야 하는 경우에는 별도의 추가 작업 없이 원본 값을 그대로 인자로 전달하도록 준비합니다⁹. 이후에는 준비한 인자들을 가지고 함수를 호출하는 CallOp를 생성하고 (exception handling 등 부가적인 요소가 있는 경우 InvokeOp 사용)¹⁰, 마지막으로 함수 호출 후 return value를 처리하게 됩니다. 만약 이 함수가 간접적으로 return을 하는 형태로 변형된 경우라면 맨 처음 할당했던 stack memory에서 return value를 읽어오는 code를 삽입하게 됩니다¹¹.

Clang에서는 위와 같은 과정으로 calling convention에 맞게 machine code를 생성할 수 있도록 IR code를 생성하고 있습니다.

Lessons learned

위와 같은 과정을 통해 Clang과 LLVM은 C/C++ code를 실제 CPU에서 실행될 수 있는 machine code로 컴파일 하게 되며, target 환경(architecture 및 OS)의 ABI에 부합하는 코드를 생성하는 방향으로 컴파일을 진행하였기 때문에 GCC나 MSVC 같은 다른 컴파일러가 생성한 machine code들과도 충돌 없이 미리 컴파일이 완료된 외부 라이브러리를 사용할 수 있게 됩니다.

Nadya 개발 과정에서 이 부분을 미처 고려하지 못해 원인 불명의 메모리 버그들과 며칠 동안 씨름한 경험이 있는데요. 이는 실제 hardware level에서 저희가 작성한 code들이 어떤 규칙들로 실행되는지 분석하고 배울 수 있는 귀중한 경험이 되었습니다.

모든 내용을 전부 다루기에는 내용이 너무 방대해 많이 축약해 설명을 드렸지만, 궁금하신 분들께는 도움이 되었으면 좋겠습니다.

<각주>

  1. llvm/include/llvm/IR/DataLayout.h

  2. LLVM에서 sret, inalloca, byref 등과 같은 attribute를 이용하면 처리를 해주는 것으로 보이나, Clang에서는 이를 사용하고 있지 않습니다.

  3. clang/include/clang/lib/CodeGen/ABIInfo.h

  4. clang/lib/CodeGen/CodeGenModule.cpp:3592

  5. CodeGenTypes::GenFunctionType(...), clang/lib/CodeGen/CGCall.cpp:1619

  6. clang/lib/CodeGen/CGCall.cpp:1662

  7. clang/lib/CodeGen/CGCall.cpp:4905

  8. clang/lib/CodeGen/CGCall.cpp:4972

  9. clang/lib/CodeGen/CGCall.cpp:5005

  10. clang/lib/CodeGen/CGCall.cpp:5544

  11. clang/lib/CodeGen/CGCall.cpp:5690

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