LLVM에서 가능한 모든 호출을 인라인하면 실제로 어떤 일이 벌어지는지 살펴본다. 인라인의 제약과 구현 세부, 충돌 이슈와 해결, 이미지 처리 코드와 LLVM 테스트 스위트를 통한 컴파일 시간·바이너리 크기·실행 성능에 미치는 영향을 실험으로 검증한다.
모두 모이세요! 오늘은 다음과 같은 흥미로운 질문을 탐구해 보겠습니다: 모든 것을 인라인하면 무슨 일이 일어날까요?
인라이닝에 익숙하지 않다면, 인라이닝은 많은 사람들(컴파일러 커뮤니티의 중요한 인물들, 예컨대 Chandler Carruth 포함)에게 최적화 컴파일러에서 단일로 가장 중요한 최적화로 간주된다는 점을 알아두면 좋습니다. 동작 방식에 대한 간단한 설명은, 우리가 LLVM Developers' Meeting에서 여러 사람들과 함께 발표한 함수 간(interprocedural) 최적화 발표의 이 부분을 참고하세요. 저는 인라이닝에 대해 이야기했습니다. 처음 약 6분만 보면 감을 잡으실 겁니다.1
이 영상에서 저는 인라이닝에 단점이 있다고 말합니다. 즉, 인라이닝이 컴파일러 도구 상자에서 최강의 도구인 것은 맞지만, 신중하게 써야 한다는 뜻입니다! 정말 그럴까요?
단점이 정확히 무엇일까요? 영상에서는 크게 세 가지를 말합니다: (a) 코드 중복, (b) 코드 크기 폭증, (c) 레지스터 할당기의 압력 증가입니다. (a)는 같은 코드가 여러 번 컴파일되므로 컴파일 시간을 늘릴 수 있고, (b)3는 실행 파일을 크게 만들어 디스크 공간을 더 차지하고 로드 시간도 늘립니다. 더 나아가, 이렇게 커진 코드 곳곳으로 점프하게 되면 이론적으로는 명령어 캐시 미스를 유발할 수 있습니다. (c)의 문제는 불필요한 레지스터 스필을 야기할 수 있다는 점이죠.
하지만 오늘은 절약 대신 과감함을 택하겠습니다. 우리의 태도는 단순합니다: 우리는 런타임 성능만 신경 쓴다! 즉, “디스크 공간도 넉넉하고, 컴파일 시간도 충분히 쓸 수 있다; 코드를 최대한 빠르게 만들어 보자!”는 뜻입니다. 다시 말해, 다른 제약을 무시하고 모든 것을 인라인한다면, 런타임 성능이 (b), (c)가 암시하듯) 더 나빠질까요? 제가 아는 한, 지금까지(실험적 증거로) 이 질문에 답한 사람은 없었습니다. 그럼 직접 확인해 봅시다.
음, 아닙니다. LLVM이 도저히 인라인할 수 없는 호출들이 있습니다;4 몇 가지 경우는 영상에서 언급했습니다. 다음 실험들에서는 코드 대부분이(표준 라이브러리 코드를 제외하고는) 컴파일러에 공개되고, 가상 함수나 함수 포인터도 많지 않습니다(우리가 직접 쓰는 코드에서는 확실히). 따라서 인라이닝의 가장 큰 장애물은 재귀일 것입니다(그리고 그것도 드뭅니다). 요컨대 대다수 함수는 인라인 가능하다는 뜻입니다.
여기서 한 가지 덧붙이자면, LLVM이 간단한 재귀 호출은 인라인할 수 있습니다. 특히 테일 재귀 함수의 호출은 가능한데, 이는 테일 재귀 호출 제거 패스가 먼저 재귀를 제거했기 때문입니다(예시). 예를 들어, 다음과 같은 간단한 팩토리얼 함수는 인라인할 수 있습니다:
int fac(int x) { if (x == 1) { return 1; } return fac(x - 1) * x; }
하지만 다음과 같은 재귀적인 피보나치 함수는 인라인할 수 없습니다:
int fib(int x) { if (x == 1) { return 1; } if (x == 2) { return 1; } return fib(x - 1) + fib(x - 2); }
Making LLVM inline everything (else)
이건 쉬워야 했는데, 그렇지 않았습니다. 인라이닝의 수지 타산(profitability), 즉 언제 인라이닝이 이득인지 결정하는 부분을 바꾸는 일은 쉬워야 합니다. 그렇게만 된다면, 인라이닝 내부를 깊이 이해하지 않고도 사람들이 수지 타산 분석을 바꿔볼 수 있겠죠. 예를 들어, 컴파일러 전문가가 아닌 머신러닝 연구자가 LLVM의 인라이닝 수지 타산 분석을 바꾸는 것이 쉬워야 합니다. 우리의 경우라면, 수지 타산 분석이 항상 “예스!”라고 말하도록 바꾸면 되니까요!
안타깝게도, 현대 컴파일러에서는 그렇게 간단하지 않습니다. 사실 저는 이 문제에 대해 논문 하나를 통째로 썼습니다. 오늘날에는 변환(transform)과 수지 타산 분석 코드가 강하게 결합되어 있어, 한쪽을 바꾸려면 다른 쪽을 이해하지 않고는 불가능합니다. 그럼에도 LLVM의 인라이닝은 다른 패스들에 비해 분리가 더 잘 된 편이라 좋은 사례에 속합니다.5 간단히 지금의 인라이닝 의사결정 흐름을 설명하겠습니다. 이 글을 쓰는 시점의 최신 LLVM 릴리스인 20.1.0을 사용합니다.
인라이닝 패스는 Inliner.cpp에서 시작합니다. 이는 Call-Graph Strongly-Connected-Component(CGSCC) 패스입니다. 이게 무슨 뜻인지 감을 잡으려면 Chandler Carruth의 이 영상 일부나, 제 영상의 후반부를 보시길 권합니다. 하지만 이 글과 크게 관련 있는 부분은 아니니, 인라이너가 각 함수를 개별적으로 방문한다고 생각해도 됩니다. TL;DR은 이렇습니다: 호출 그래프를 만들고,6 이를 강연결요소(SCC)로 나눈 뒤 각각을 따로 방문합니다.7
앞서 말했듯, 인라이닝의 복잡성 대부분은 수지 타산 분석에서 옵니다. 어떤 함수 호출을 인라인하는 게 이득인지 결정하기 위해 LLVM은 InlineAdvisor라는 것을 사용합니다. 결국 여기에서 그 자문을 구합니다.
... std::unique_ptr<InlineAdvice> Advice = Advisor.getAdvice(*CB, OnlyMandatory); ...
호출을 따라가다 보면 여기에 도착합니다.
std::unique_ptr<InlineAdvice> InlineAdvisor::getAdvice(CallBase &CB, bool MandatoryOnly) { ...
이제, 특별한 인라인 어드바이저(예: size용)를 쓰지 않는다면 여기에 도착합니다.
std::optionalllvm::InlineCost static getDefaultInlineAdvice( CallBase &CB, FunctionAnalysisManager &FAM, const InlineParams &Params) { ...
코드에서 분명히 보이듯, 우리가 집중해야 할 건 shouldInline() 호출입니다.
... return llvm::shouldInline( CB, CalleeTTI, GetInlineCost, ORE, Params.EnableDeferral.value_or(EnableInlineDeferral));
하지만 실제로 중요한 건 여기의 이 람다입니다.
auto GetInlineCost = [&](CallBase &CB) { bool RemarksEnabled = Callee.getContext().getDiagHandlerPtr()->isMissedOptRemarkEnabled( DEBUG_TYPE); return getInlineCost(CB, Params, CalleeTTI, GetAssumptionCache, GetTLI, GetBFI, PSI, RemarksEnabled ? &ORE : nullptr); };
호출을 계속 따라가면 우리가 관심 있는 코드, 즉 getInlineCost()에 도착합니다. 특히 이 줄이 중요합니다.
... InlineResult ShouldInline = CA.analyze(); ...
이제 getInlineCost() 내부의 나머지 코드를 보면, 성공 또는 실패를 반환한다는 걸 알 수 있습니다. 그래서 여러분은 LLVM이 모든 것을 인라인하도록 만드는 일이 이렇게 간단하다고 생각할 수도 있겠습니다: 사용자의 결정만 존중하고, 그 외에는 여기에
return InlineCost::getAlways("Decided by our
majesty")
를 추가하면 되겠다고요여기. 자, 이렇게 고치고 어떻게 되는지 봅시다.
이를 테스트하기 위해, 저는 C로 간단한 이미지 처리 코드를 작성했고, 퍼블릭 도메인인 stb_image* 계열 라이브러리를 사용했습니다. 코드는 아래 섹션을 펼치면 더 자세히 볼 수 있습니다.
Image Processing Code 간단한 이미지 처리 파이프라인으로, JPEG 이미지를 로드하고 리사이즈한 뒤 PNG로 저장합니다. 테스트에 사용한 이미지는 저작권 없는 다음의 간단한 이미지들입니다: Photo by M Venter, Photo by Lukas Kloeppel, Photo by eberhard grossgasteiger
#define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_RESIZE_IMPLEMENTATION #define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image.h" #include "stb_image_resize2.h" #include "stb_image_write.h"
#include <assert.h> #include <stdio.h> #include <stdlib.h> #include <stdint.h>
#define NUM_IMAGES 3 #define MAX_RES_HEIGHT 5000 #define MAX_RES_WIDTH 5000 #define MAX_CHANNELS 3
void load_resize_write(const char *in, const char *out, uint8_t *resized_image) { int width, height, channels;
uint8_t *input_image = stbi_load(in, &width, &height, &channels, 0); assert(input_image); assert(channels <= MAX_CHANNELS);
int new_width = width / 1.3; int new_height = height / 1.3;
assert(new_width < MAX_RES_WIDTH); assert(new_height < MAX_RES_HEIGHT);
stbir_resize_uint8_linear(input_image, width, height, 0, resized_image, new_width, new_height, 0, (stbir_pixel_layout)channels);
assert(stbi_write_png(out, new_width, new_height, channels, resized_image, new_width * channels));
stbi_image_free(input_image); }
int main() { uint8_t *resized_image = malloc(MAX_RES_WIDTH * MAX_RES_HEIGHT * MAX_CHANNELS); assert(resized_image); char in[256]; char out[256];
for (int i = 0; i < NUM_IMAGES; ++i) { sprintf(in, "./in/input%d.jpg", i); sprintf(out, "./out/out%d.png", i); load_resize_write(in, out, resized_image); }
return 0; }
다음과 같이 파일을 컴파일해 봅시다:
time <path to custom clang build>/bin/clang resize.c -O3 -Rpass=inline
인라이너를 동작시키려면 -O3 같은 플래그로 최적화를 켜야 합니다. 또 -Rpass=inline으로 어떤 함수가 성공적으로 인라인되었는지, -Rpass-missed=inline으로 어떤 함수가 인라인되지 않았는지 LLVM에 출력하도록 요청합니다. 이걸 실행하면, Clang이 크래시납니다. 제가 얻은 출력은 여기 있습니다.
여기서 관찰할 점이 몇 가지 있습니다. 첫째, 컴파일 시간이 엄청 깁니다! 제 Mac Studio에서는 약 39초가 걸렸습니다. 터무니없이 깁니다(최적화 빌드의 LLVM을 사용하고 있다는 점에 유의). 곧 바닐라 Clang으로 동일 파일을 컴파일해 차이를 보겠습니다. 둘째, 거의 모든 함수가 인라인되었습니다. -Rpass-missed 출력에는 고작 22줄만 있습니다. 그 미스된 함수들은 곧 보겠지만, 우선 인라인된 함수들에 집중해 봅시다.
소스 코드에 __always_inline__ 속성이 붙어 있어서 인라인된 함수들이 여럿 있습니다(이는 inline과 다릅니다. 자세한 내용은 제 이전 글을 보세요). 흥미롭게도, 이 함수들은 stb_image* 자체의 일부가 아니라 인트린식입니다. 예컨대 제 Mac에서는 내장된 Clang 인클루드 파일을 사용하며, 그 안에 이런 것이 있습니다:
__ai attribute ((target("neon"))) float32x4_t vreinterpretq_f32_u8(uint8x16_t __p0) { float32x4_t __ret; __ret = (float32x4_t)(__p0); return __ret; }
여기서 __ai가 역할을 합니다. 정의는 다음과 같습니다:
#define __ai static inline attribute((always_inline, nodebug))
“always inline” 라인들을 모두 제거하면, 다른 모든 “인라인됨” 메시지에는 “Decided by our majesty”가 포함돼 있습니다. 이는 (a) 우리가 수정한 코드가 실행되었음을 검증하고, (b) 실제로 거의 모든 것을 인라인하고 있음을 보여줍니다.
그렇다면 인라인되지 않은 함수들은 무엇일까요? 22개 케이스 모두 같은 메시지를 줍니다: “noinline call site attribute”. LLVM의 속성(attributes)에 대해 더 알고 싶다면, 아래 섹션을 펼치세요.
LLVM Attributes
LLVM에서 속성은 명령어, 함수 같은 다양한 엔티티에 붙일 수 있습니다. 속성은 어떤 정보를 전달합니다. 예를 들어, 함수는 인라인하지 말라거나, 포인터가 다른 어떤 포인터와도 에일리어싱하지 않는다는 것(LLVM 내부에선 noalias라 하며, C의 restrict에 해당)을 나타냅니다. 매뉴얼에서 볼 수 있듯, LLVM은 속성을 아주 광범위하게 사용합니다. 작은 코드 조각만 컴파일해도 속성이 잔뜩 붙은 LLVM IR을 보게 됩니다.
noinline 속성은 여러 곳에 붙일 수 있고, 함수 정의에 붙으면 그 함수에 대한 어떤 호출도 인라인하지 말라는 뜻입니다. 실제로 봅시다. 간단한 예입니다. 보시다시피 -O1에서도 square()는 인라인됩니다. 이제 LLVM IR을 수동으로 생성해 noinline 속성을 추가해 보겠습니다. godbolt 예시. 그런데 그 전에 명령행 인자들이 무엇인지 잠깐 설명해야겠습니다.
Interlude: Getting optimizable LLVM IR from Clang
-emit-llvm은 LLVM IR을 내보내게 하지만, 기본적으로 바이너리 포맷(즉 .bc)으로 내보냅니다. 사람이 읽을 필요가 없다면 이 방식이 더 좋습니다. 하위 호환성, 크기, 로드 속도 측면에서요. 하지만 우리는 읽고 싶으니 텍스트 버전으로 출력하는 -S도 추가합니다. -g0는 디버그 정보를 빼서 출력을 덜 지저분하게 합니다. 나머지가 중요한 부분입니다.
우리는 “최적화되지 않은” 코드를 원합니다. 왜냐하면 속성을 추가한 뒤 직접 옵티마이저를 통과시킬 것이기 때문입니다. 하지만 “최적화 가능(optimizable)”해야 합니다. 여기서의 뜻은 옵티마이저가 이를 무시하지 말아야 한다는 것이죠. 다른 명령행 인자를 추가하지 않으면, “최적화 불가(un-optimizable)” 코드를 얻게 됩니다. 이 예시를 보세요. 두 함수 모두 #0 속성에 optnone이 있습니다. 이는 옵티마이저에게 이 함수를 전혀 건드리지 말라고 지시하는 속성으로, 그게 목적이라면 아주 좋습니다. 이 LLVM IR을 그대로 최적화해보면 어떻게 될까요: godbolt. 보시다시피 -O3조차 아무것도 하지 않습니다.
이를 해결하는 한 가지 방법은 그냥 optnone을 수동으로 제거하는 것입니다: godbolt. 그러면 우리가 원하는 noinline만 남습니다.
하지만 일반적으로 코드를 일일이 바꾸고 싶진 않습니다. 대부분은 -O0에 -Xclang -disable-O0-optnone을 더해, Clang 드라이버가 optnone을 붙이지 않도록 합니다: godbolt. 하지만 이 경우에도(위에서 봤듯) 함수에 기본적으로 noinline이 붙습니다8. 보통 이건 원치 않는 일입니다. 인라이너를 돌리고 싶으니까요. 내가 사용한 -O1 -Xclang -disable-llvm-passes는 -O1로 optnone과 noinline의 추가를 피하면서, 드라이버에게 패스들을 돌리지 말라고 지시합니다. 우리가 원하는 바죠.
--- End of the interlude...
우리가 직접 noinline을 추가하든, 이미 들어간 LLVM IR을 받든, 그 결과 square()는 인라인되지 않습니다: godbolt. 마지막으로 덧붙이자면, 특정 호출 지점(call site)에 noinline을 붙일 수도 있는데, 이는 우리가 처음 맞닥뜨린 문제와 관련이 있습니다. 예시를 보면 square()를 두 곳에서 호출합니다. 이제 bar() 안의 호출에 속성을 붙일 수 있습니다: godbolt. 보시다시피 bar() 안의 호출은 인라인되지 않았고, foo() 안의 호출은 인라인되었습니다.
이제 LLVM 속성에 대해 알게 되었으니, C 레벨에서 GCC 확장 __attribute__((noinline))(Clang도 지원)을 통해 LLVM에 noinline을 추가할 수 있다는 점을 말해두겠습니다. 이 스니펫에서 square()는 noinline을 받지만 foo()는 받지 않습니다. 다만, 특정 호출 지점에 이 속성을 붙이는 방법은 저는 모릅니다(정 필요하다면, 호출만 수행하는 헬퍼 함수를 만든 뒤 그 헬퍼에 속성을 붙이는 방법이 있습니다).
어쨌든 시급한 질문은 이겁니다: 누가 이 속성을 붙였나? 우리는 최적화를 켜고 컴파일했으니, 분명 -O0라서 그런 건 아닙니다. 또 메시지를 기억하세요: “noinline call site attribute”. 호출 지점에 수동으로 붙일 방법은 없고, 코드 어디에도 함수 레벨 속성이 포함되어 있지 않으니, LLVM 컴파일 파이프라인 어딘가가 이를 자동으로 추가했다는 뜻입니다. 적어도 두 가지 질문이 생깁니다. 첫째, 어느 부분이 이를 추가했고, 둘째, 왜 우리는 그럼에도 불구하고 인라인하지 않았나? 우리는 모든 걸 인라인하기로 한 것 아닌가요?
두 번째 질문은 쉽게 답할 수 있습니다. 이 메시지는 여기와 여기에 나타납니다. 두 군데라서 헷갈릴 수 있으니, 어느 쪽인지 접미사를 붙여 확인해 봅시다(저는 첫 번째에 “1”, 두 번째에 “2”를 붙였습니다). 새 바이너리로 실행해보면 모든 경우가 두 번째 장소에서 비롯됨을 볼 수 있습니다. 이는 예상된 일입니다. 첫 번째는 noinline과 함께 alwaysinline이 있는지 확인하기 때문이죠. stb_image*는 진지한 코드이므로, 인라인 불가능한 함수에 alwaysinline을 붙였을 리 없습니다. 요컨대, 인라인되지 않은 호출 지점들은 모두 파이프라인 어딘가가 자동으로 속성을 추가했기 때문입니다.
그런데도 왜 인라인하지 않았을까요? 우리가 여기에 코드를 추가해 사용자의 결정을 존중하게 했다는 점을 기억하세요. 보시다시피 사용자 결정은 getAttributeBasedInliningDecision()에 기반합니다. 이 함수는 위에서 언급한 두 곳(즉 noinline 확인)을 포함합니다. 그래서 우리는 이 호출들을 인라인하지 않았던 겁니다(분명 이 경우 “사용자”—즉 프로그래머—의 결정은 아니었는데도요).
첫 번째 질문은 조금 더 미묘합니다. 어느 부분이 왜 속성을 추가했을까요? 물론 LLVM이 멋대로 noinline을 붙이지는 않습니다. 붙였다면 인라인할 수 없다는 뜻일 겁니다. 어떤 함수들이 인라인될 수 없었는지 보죠. 리마크 메시지를 보면 22개 케이스 모두 stbi__out_gif_code() 함수와 관련됩니다. 함수 코드를 보면 문제가 보입니다. 재귀입니다. 여기를 보세요. 그러니 LLVM이 이 속성을 붙인 이유가 이것이라는 가설은 합리적입니다. 확인하는 게 좋겠죠.
요컨대, 답은 예스입니다. noinline 속성은 여기에서 추가됩니다. 정확한 이유는 조금 복잡하고, 우리가 LLVM 인라이너의 약간 모서리 케이스를 건드리고 있으므로 자세한 설명은 생략하겠습니다. 하지만 재귀라서 그렇다고 생각하는 건 그리 틀리지 않습니다.
그런데 기억하세요, Clang이 “크래시”났습니다! 왜 그런지 알아내야 합니다. 제 Mac Studio에서는 .crash 파일에 접근할 수 없었습니다. 다행히 에러 메시지가 문제를 알아낼 만큼 충분한 정보를 줍니다: 가변 인자 함수를 사용하는 어떤 함수를 인라인한 겁니다. 소스 코드에서 가변 인자를 사용하는 함수는 이것과 이것 두 개입니다. 여기에 __attribute__((noinline))을 추가하면 문제가 해결됩니다(그러면 컴파일 시간은 약 50초로 더 뛰어오릅니다).
하지만 더 큰 문제는, 수지 타산 분석만 건드리려던 우리의 의도에도 불구하고, 실제 변환을 망가뜨렸다는 점입니다. 앞서 말한 문제를 잘 드러내죠. 다행히 간단한 해결책이 있습니다. 앞서 언급한 “사용자 결정”을 기억하시죠. 그 함수에서 사용자가 alwaysinline을 썼지만, 함수에 noinline도 있다면, 첫 번째 “noinline call site attribute” 리마크를 발생시킵니다. 그런데 그 직후, 만약 noinline 속성이 없다면, 함수가 인라인 가능(inline-viable)한지 확인합니다!
즉 LLVM은 isInlineViable을 제공해, 함수가 인라인 가능한지 확인해 줍니다. 그리고 여기에서 이 함수가 해당 함수가 va_start()를 사용하면 false를 반환한다는 걸 볼 수 있습니다. 이제 인라인 가능한 것은 모두 인라인하도록 코드를 다음처럼 바꿀 수 있습니다:
if (isInlineViable(*Callee).isSuccess()) { return InlineCost::getAlways("Decided by our majesty"); }
그리고 이제 원래 코드에 어떤 것도 바꾸지 않고 전체를 컴파일할 수 있습니다! 어쩌면 놀랍게도, 실행 파일도 에러 없이 실행되고, 리사이즈되어 저장된 이미지도 멀쩡해 보입니다. 실행 파일과 그 실행 시간에 대해서는 더 할 말이 있지만, 우선 복잡한 것들도 컴파일이 되는지 확인해야 합니다.
The true crash test: Compiling the LLVM test suite
LLVM test suite에는 여러 애플리케이션과 벤치마크가 포함되어 있습니다. 일반적인 컴파일러, 특히 LLVM에 대한 공개 크래시 테스트라 할 수 있죠. 다음 CMake 설정으로 테스트 스위트를 구성했습니다(맥 특화 항목 제외):
cmake ../test-suite
-DCMAKE_C_COMPILER=<path to custom build>/bin/clang
-DCMAKE_CXX_COMPILER=<path to custom build>/bin/clang++
-C../test-suite/cmake/caches/O3.cmake
다음 옵션을 추가하면 우리의 수정이 적용되었음을 확인할 수 있지만, 출력이 지저분해집니다:
-DCMAKE_C_FLAGS="-Rpass=inline"
-DCMAKE_CXX_FLAGS="-Rpass=inline"
저는 보통 SingleSource/Benchmarks와 MultiSource/Benchmarks를 빌드합니다. 이번 경우에도 나중에 유용할 겁니다. 제 환경에서는 몇 개 벤치마크가 컴파일되지 않았는데, 바닐라 Clang 빌드에서도 마찬가지로 실패했기에 문제로 보지 않았습니다. 실패 이유는 제 LLVM 빌드에서 compiler-rt를 빌드하지 않았기 때문인데, 맥에서는 이것이 좀 까다롭습니다. 두 빌드 모두에서 컴파일되지 않은 벤치마크 목록은 다음과 같습니다:
SingleSource/Benchmarks/CoyoteBench/fftbench MultiSource/Benchmarks/DOE-ProxyApps-C/RSBench
또한 다음 벤치마크는 비활성화했습니다:
MultiSource/Benchmarks/tramp3d-v4/ MultiSource/Benchmarks/PAQ8p
항상-인라인 빌드에서 컴파일 시간이 너무 오래 걸렸기 때문입니다.
On comparing Clang builds
내장 Clang과 여러분이 직접 빌드한 Clang을 비교하지 마세요. 내장 Clang은(예: assertions 없이 또는 compiler-rt 포함 등) 빌드 방식이 다를 수 있습니다. 그러면 부정확한 비교가 됩니다. 가장 쉬운 방법은 LLVM 소스 코드를 두 번 내려받아 각 소스에 대해 별도의 빌드 디렉터리를 쓰는 것입니다. 물론 하나의 소스 디렉터리로도 가능하지만, 바닐라 빌드를 만들고 나서 코드를 수정하지 않는다는 보장이 필요합니다. 아예 분리된 소스를 쓰면, 해당 소스는 손대지 않으면 됩니다. 실수로 바닐라에서 재컴파일을 눌러도, 아무것도 바뀌지 않았다는 걸 알 수 있죠.
어쨌든 두 빌드 모두 같은 CMake 설정을 사용해야 합니다. 다음 실험들에서는 다음을 사용했습니다:
cmake ../llvm -DLLVM_ENABLE_PROJECTS='clang;clang-tools-extra' -DLLVM_TARGETS_TO_BUILD=host -DCMAKE_BUILD_TYPE='Release' -DLLVM_ENABLE_ASSERTIONS=ON -G Ninja
이번 경우엔 assertions를 켜고 빌드해도 성능에 문제 없습니다. 인라이닝 오버헤드가(강하게) 비교를 지배할 것이기 때문입니다.
비슷한 이유로 테스트 스위트도 두 개 빌드해 두는 것이 좋습니다. 다만 이 경우는 하나의 소스 디렉터리를 써서 변경사항이 양쪽에 모두 적용되도록 하는 편이 낫습니다.
그 외에는 모두 성공적으로 컴파일됩니다. MultiSource/Applications의 애플리케이션도 컴파일해 볼 수 있습니다. 다만 sqlite3를 컴파일하려 하면 매우 오래 걸린다는 점은 유의하세요.
How bad (or not) is it to inline everything?
이제 가장 중요한 질문에 답할 시간입니다. 정말로 모든 것을 인라인하면(컴파일러 개발자들이 말하듯) 문제가 생길까요?
Compilation times and executable size
우선, 처음에는 이 지표들을 신경 쓰지 않겠다고 했지만, 그래도 살펴보면 흥미롭습니다. 이미지 처리 파이프라인은 항상-인라인 빌드로(-O3) 컴파일하면 무려 50초 가까이 걸립니다. 반면 바닐라
clang
-O3
으로는 약 2초만 걸립니다. 대략 25배 느립니다!
실행 파일 크기 측면에서는, 바닐라 Clang이 약 304KB짜리 실행 파일을 내는 반면, 항상-인라인 Clang은 3.4MB로 약 10배 큽니다.
요약하면, 이 지표들에 대해서는 결코 좋아 보이지 않습니다. 한 가지 기억할 점은, 소스 코드 크기가 커질수록 컴파일 시간과 실행 파일 크기는 “지수적으로” 증가한다는 것입니다. 예컨대 A()가 B()를 호출하고 B()가 C()를 호출한다면, C()를 B()에 인라인하고, B()를 A()에 인라인하면, C()의 코드는 두 번 컴파일됩니다. 한 번은 B()의 바디를 컴파일할 때, 또 한 번은 A()의 바디를 컴파일할 때. 또한 X()가 Y()와 Z()에서 호출되는데 두 곳 모두 인라인하면, 역시 그 코드는 두 번 컴파일됩니다. 얼마나 빠르게 폭발할 수 있는지 감이 오실 겁니다.
이 때문에 대규모 애플리케이션이라도 작은 컴파일 단위가 많다면 여전히 감당 가능하지만, 적은 수의 거대한 컴파일 단위로 구성되어 있다면 감당하기 어렵습니다. 예를 들어, MultiSource/JM은 약 6.5만 LoC이고, MultiSource/sqlite3는 약 5.3만 LoC입니다. 하지만 JM의 6.5만 LoC는 여러 컴파일 단위로 분산되어 있고, sqlite3는(포함된 .h까지 합쳐) 단일 컴파일 단위(sqlite3.c)입니다. 그래서 JM을 컴파일하는 데 약 25초가 걸리는 반면, sqlite3는... 4시간이 지나도 끝나지 않아 포기했습니다.
마침내 가장 중요한 질문으로 돌아갑니다. 성능 저하가 있을까요?
이미지 처리 파이프라인부터 시작합시다. 이 경우 답은 분명 “아니오”로 보입니다. 두 버전 모두 -O3에서 약 2.9초가 걸립니다. -O2도 두 빌드 사이 차이가 없습니다9. -O1은 두 컴파일 간 차이가 없지만(다만 -O2, -O3과 비교하면 약 3.3초로 느려집니다).
이제 테스트 스위트를 비교해 봅시다. 벤치마크는(각 빌드마다 한 번씩) 다음으로 실행했습니다:
<path to build>/bin/llvm-lit -sv -j 1 -o results.json ./SingleSource/Benchmarks/ ./MultiSource/Benchmarks/
전체 벤치마크 소요 시간으로 보면, 바닐라 빌드는 약 258초, 항상-인라인 빌드는 약 260초가 걸렸습니다. 309개 테스트를 대상으로 하면 사실상 차이가 없습니다. 두 개의 .json 파일을 사용해, 아래 Python 코드로 특정 벤치마크에서 유의미한 차이가 있는지 볼 수 있습니다.
Python code to compare results
import pandas as pd import json from scipy.stats import gmean
def read_json(f): with open(f, 'r') as fp: d = json.load(fp) return d['tests']
def process_tests(tests): names = [] times = [] for t in tests: name = t['name'] name = name[len("test-suite :: "):] name = name[:-len('.test')] name = name.replace('MultiSource/Benchmarks', 'MS') name = name.replace('SingleSource/Benchmarks', 'SS') names.append(name) times.append(t['metrics']['exec_time'])
return names, times
van = read_json('results_vanilla.json') inl = read_json('results_inline.json')
names_van, times_van = process_tests(van) names_inl, times_inl = process_tests(inl) assert names_van == names_inl
ser_van = pd.Series(times_van, index=names_van) ser_inl = pd.Series(times_inl, index=names_van)
ser_rel1 = ser_van / ser_inl ser_rel2 = ser_inl / ser_van ser_abs = ser_van - ser_inl df = pd.DataFrame(data={'rel1': ser_rel1, 'rel2': ser_rel2, 'abs': ser_abs, 'van': ser_van}, index=ser_rel1.index) print('--- 5 fastest benchmarks with always-inline') print(df.nlargest(50, columns=['rel1'])) print() print('--- 5 fastest benchmarks with vanilla') print(df.nlargest(50, columns=['rel2'])) print('-----') m_perc = (gmean(ser_rel1) - 1) * 100 print(f'Geomean relative speedup of inline vs vanilla: {m_perc:.3}%')
아래 접을 수 있는 섹션을 펼치면 자세한 결과를 볼 수 있습니다:
Detailed results
--- 5 fastest benchmarks with always-inline rel1 rel2 abs van MS/Prolangs-C++/fsm/fsm 4.166667 0.240000 0.0019 0.0025 MS/Prolangs-C/unix-tbl/unix-tbl 3.375000 0.296296 0.0019 0.0027 MS/Prolangs-C++/family/family 3.000000 0.333333 0.0012 0.0018 SS/Stanford/FloatMM 2.904555 0.344287 0.0878 0.1339 MS/Prolangs-C++/deriv2/deriv2 2.714286 0.368421 0.0012 0.0019 MS/Prolangs-C/simulator/simulator 2.714286 0.368421 0.0012 0.0019 MS/Prolangs-C/assembler/assembler 2.666667 0.375000 0.0015 0.0024 MS/Prolangs-C/unix-smail/unix-smail 2.566667 0.389610 0.0047 0.0077 MS/Prolangs-C++/trees/trees 2.250000 0.444444 0.0010 0.0018 MS/Prolangs-C/allroots/allroots 2.166667 0.461538 0.0007 0.0013 MS/Prolangs-C/agrep/agrep 2.111111 0.473684 0.0050 0.0095 MS/Prolangs-C++/simul/simul 1.933333 0.517241 0.0014 0.0029 MS/Prolangs-C++/garage/garage 1.812500 0.551724 0.0013 0.0029 SS/Polybench/linear-algebra/solvers/durbin/durbin 1.800000 0.555556 0.0028 0.0063 MS/MiBench/security-sha/security-sha 1.660000 0.602410 0.0033 0.0083 MS/Prolangs-C/gnugo/gnugo 1.639269 0.610028 0.0140 0.0359 MS/Prolangs-C/bison/mybison 1.586207 0.630435 0.0017 0.0046 MS/BitBench/uudecode/uudecode 1.581395 0.632353 0.0025 0.0068 MS/Prolangs-C++/vcirc/vcirc 1.571429 0.636364 0.0004 0.0011 MS/mediabench/g721/g721encode/encode 1.533019 0.652308 0.0113 0.0325 MS/TSVC/Symbolics-flt/Symbolics-flt 1.528159 0.654382 0.1116 0.3229 MS/Prolangs-C++/office/office 1.500000 0.666667 0.0004 0.0012 MS/mediabench/gsm/toast/toast 1.500000 0.666667 0.0030 0.0090 SS/Shootout-C++/Shootout-C++-moments 1.469231 0.680628 0.0061 0.0191 MS/MiBench/security-rijndael/security-rijndael 1.469072 0.680702 0.0091 0.0285 SS/McGill/exptree 1.400000 0.714286 0.0002 0.0007 MS/Prolangs-C++/employ/employ 1.393443 0.717647 0.0024 0.0085 MS/mediabench/adpcm/rawcaudio/rawcaudio 1.384615 0.722222 0.0005 0.0018 MS/Prolangs-C/compiler/compiler 1.380952 0.724138 0.0008 0.0029 MS/MiBench/consumer-jpeg/consumer-jpeg 1.357143 0.736842 0.0005 0.0019 MS/Prolangs-C/cdecl/cdecl 1.346154 0.742857 0.0009 0.0035 SS/Shootout-C++/EH/Shootout-C++-except 1.320276 0.757417 0.0556 0.2292 SS/BenchmarkGame/n-body 1.295739 0.771760 0.0354 0.1551 SS/Stanford/Oscar 1.285714 0.777778 0.0002 0.0009 SS/Misc-C++-EH/spirit 1.282140 0.779946 0.2706 1.2297 MS/FreeBench/mason/mason 1.266839 0.789366 0.0103 0.0489 MS/McCat/08-main/main 1.253731 0.797619 0.0017 0.0084 MS/MiBench/office-stringsearch/office-stringsearch 1.250000 0.800000 0.0002 0.0010 SS/Misc/flops-8 1.233927 0.810421 0.0302 0.1593 SS/Polybench/linear-algebra/blas/gesummv/gesummv 1.200000 0.833333 0.0001 0.0006 SS/Shootout-C++/Shootout-C++-hello 1.200000 0.833333 0.0001 0.0006 MS/Prolangs-C++/ocean/ocean 1.185714 0.843373 0.0026 0.0166 MS/DOE-ProxyApps-C/SimpleMOC/SimpleMOC 1.181476 0.846399 0.0578 0.3763 MS/McCat/17-bintr/bintr 1.174089 0.851724 0.0043 0.0290 SS/Misc/himenobmtxpa 1.159236 0.862637 0.0175 0.1274 SS/Stanford/Queens 1.157895 0.863636 0.0006 0.0044 MS/TSVC/CrossingThresholds-flt/CrossingThreshol... 1.117769 0.894640 0.0684 0.6492 MS/BitBench/uuencode/uuencode 1.114754 0.897059 0.0007 0.0068 MS/MiBench/consumer-typeset/consumer-typeset 1.107527 0.902913 0.0040 0.0412 SS/Stanford/Bubblesort 1.103896 0.905882 0.0008 0.0085
Geomean relative speedup of inline vs vanilla: 4.66%
여기서 무엇을 알 수 있을까요? 이 설정에서는 차이가 너무 작아(아래의 향후 과제 참고) 강한 결론을 내리긴 어렵습니다. 하지만 굳이 말하자면, 모든 것을 인라인하면 더 빨라집니다. 결과는 어쨌든 항상-인라인 버전이 기하평균 기준 4.66%의 속도 향상을 보인다고 말하니까요.
위의 실험적 증거만으로 “모든 것을 인라인하면 더 느린 실행 파일이 생성된다”고 결론 내릴 수는 “없습니다”. 다만 “컴파일 시간과 실행 파일 크기가 부풀어 오른다”는 사실은 확실히 결론낼 수 있습니다. 개인적으로 저는 이번 실험에서 이렇게 받아들였습니다: __attribute__((always_inline))를 두려워하지 말자. 그렇게 말하는 이유는, 컴파일러 커뮤니티의 중요한 인물들이 이것을 아예 쓰지 말라고 권하기 때문입니다! 그 대표적 인물이 Chandler Carruth입니다. 이 영상에서 그는 이렇게 말합니다:
[T]here are special implementation tricks, like the always_inline attribute, that GCC supports, and ICC supports, and Microsoft [i.e., MSVC] supports—everyone has one of these attributes, right? I would strongly encourage to not use that attribute—ever, okay? If you're using that attribute, you have found a bug in the inliner of your optimizer. If you're going to use the attribute, I would ask: please, please, file a bug against the optimizer first, and then add the attribute with a little comment that says: “by the way, we should remove this as soon as they fix this bug over here that's hurting our performance”. It should always be based on performance measurements, and you should always file a bug with the complaint to the optimizer about “why did you not actually inline this?”.
저는 이 주장에 100% 동의한다고는(말장난 의도) 확신하지 못하겠습니다. 예, 결정은 성능 측정에 기반해야 한다는 점에는 동의합니다. 옵티마이저에 버그를 보고해야 한다는 점에도 동의합니다. 하지만 “절대 쓰지 말라”는 건 전혀 다른 이야기입니다. LLVM 같은 소프트웨어는 느리게 움직이고, 모든 것이 고쳐질 때까지 기다릴 수만은 없습니다. 특히 점점 더 머신러닝 기반 도구로 향하고 있는 지금은, 특수 케이스를 고치는 일이 if 하나 추가하는 것만큼 쉽지 않으니까요. 그래서 저는 위에서 완화한 주장—즉, 사용하면서 버그도 보고하자는—이 실제로는 더 타당하다고 봅니다.
주석을 달아야 하느냐에 대해서는... 잘 모르겠습니다. 사람들은 LLVM에서 이 인라이닝 버그가 고쳐졌는지 아닌지보다 더 신경 쓸 일이 많습니다. 솔직히, 누가 신경이나 쓰나요? 저 자신이 컴파일러 애호가임에도 이런 말을 합니다. 대부분은 그렇지 않죠. 여기저기 always_inline을 조금 붙이는 일이 세상의 끝은 아닙니다. 우리의 실험이 보여줬듯 말입니다. 특히 성능 측정에 근거해 선택적으로 사용하는 경우에는 더더욱요. 우리가 한 것처럼 마구잡이로 붙이는 것과는 다릅니다.
의견을 나누고 싶으신가요? 저에게 이메일을 보내주세요.
업데이트를 놓치고 싶지 않으신가요? 이 RSS 피드를 팔로우하거나 뉴스레터를 구독하세요: