Link Time Optimization
바로 앞 글에서는 가비지 컬렉션으로 코드를 줄이는 법을 알아봤다. 하지만 이 방법은 코드의 수정도 필요하고, 여러 모로 노력이 많이 들어간다. 이번 글에서는 더 강력하고 쉬운 방법인 LTO(Link Time Optimization)를 알아본다.
Dead-code Elimination
앞 글에서의 예제를 다시 가져와본다.
int foo(void) { return 1; }
int bar(void) { return foo() + 2; }
int main(void) { return foo() + 4; }
.text
.type foo, %function
foo:
mov r0, #1
bx lr
.type bar, %function
bar:
push {r3, lr}
bl foo
adds r0, r0, #2
pop {r3, pc}
.type main, %function
main:
push {r3, lr}
bl foo
adds r0, r0, #4
pop {r3, pc}
위 코드에서 bar()는 실제로 호출되지 않음에도 불구하고 코드에 포함이 되었는데, 그건 컴파일 시점에서 bar()를 호출하는지를 알 수 없기 때문이다. 하지만 함수가 파일 내에서만 사용될 경우 static 함수로 만들면 파일 내에서만 호출이 되는지 확인하면 되므로 쉽게 최적화를 할 수 있다.
static int foo(void) { return 1; }
static int bar(void) { return foo() + 2; }
int main(void) { return foo() + 4; }
.text
.type main, %function
main:
mov r0, #5
bx lr
이때 bar()의 코드가 제거되었을 뿐만 아니라 foo()가 인라인 함수로 컴파일이 되어서 main()이 매우 간단해졌다. 그럼 여기서 질문을 해본다. 모든 소스코드를 하나의 소스로 합친 다음에 static을 붙이면 bar()처럼 최적화를 할 수 있지 않을까?
Link Time Optimization (LTO)
물론 현실적으로 소스코드를 하나의 파일로 관리할 수는 없다. 하지만 LTO를 활용해서 비슷한 원리로 최적화를 할 수 있다. 컴파일러에게 LTO 모드로 컴파일하라고 지시하면, 컴파일러는 모든 소스코드를 최적화없이 내부 표현으로 변환한다. 이렇게 하면 링커가 링크를 할 때 오브젝트 파일이 한 곳에 모이게 되므로 ㅡ 소스코드를 하나의 파일로 합친 것과 같은 상태가 된다 ㅡ 물론 합쳐진 것이 소스코드는 아니고 컴파일러의 내부 표현이지만 말이다. 이렇게사용되지 않는 코드를 쉽게 최적화할 수 있다.
한 번 foo(), bar(), main()의 예제를 LTO 모드로 컴파일해서 확인해보자. foo()와 bar()가 간단하게 제거된 것을 확인할 수 있다.
$ gcc -O2 -flto -c foo.c
$ gcc -O2 -flto -c bar.c
$ gcc -O2 -flto -c main.c
$ gcc -O2 -flto -o test foo.o bar.o main.o
$ nm test | grep "foo\|bar\|main"
000102c0 T main
$ objdump -d test
[...]
000102c0 <main>:
102c0: e3a00005 mov r0, #5
102c4: e12fff1e bx lr
LTO and kernel
아쉽게도 메인라인 커널에는 LTO 패치가 머지되지 않았다. 이유는 메일링 리스트를 찾아봐야할 것 같다. 이 글에서는 Andi Kleen의 트리에서 (lto-415-2) 작업한다. 5.12-3 버전도 있다.
Numbers Please!
이제 구체적으로 LTO, Linker Section Garbage Collection이 얼마나 코드를 줄이는지 확인해보자. 우선 아무것도 적용하지 않았을 때를 보자.
$ make stm32_defconfig
$ make vmlinux
$ size vmlinux
text data bss dec hex filename
1704024 144732 117660 1966416 1e0150 vmlinux
그리고 LTO를 활성화했을 때를 보자.
$ ./scripts/config --enable CONFIG_LTO_MENU
$ make vmlinux
$ size vmlinux
text data bss dec hex filename
1281644 142492 112985 1537121 177461 vmlinux
크기가 약 22%정도 줄어든 것을 볼 수 있다. 이번에는 아까 설명했던 Linker Section Garbage Collection을 적용해보자.
$ [hacks for CONFIG_LD_DEAD_CODE_DATA_ELIMINATION]
$ make vmlinux
$ size vmlinux
text data bss dec hex filename
1304516 141672 113108 1559296 17cb00 vmlinux
이것도 크기가 대략 21%정도 줄어든 것을 볼 수 있다. 하지만 아까 살펴봤듯 Garbage Collection은 노력이 많이 들어갈 뿐더러 디버깅과 같은 몇몇 기능을 사용할 수 없게 되기 때문에 LTO가 훨씬 효과적이고 많이 사용된다.
More Numbers
LTO는 바이너리의 크기를 효과적으로 줄여주는 대신 컴파일 시간을 더 많이 소모한다. 다음은 LTO를 비활성화 했을 때의 시간이다.
$ make clean
$ make stm32_defconfig
$ time make -j8 vmlinux
real 0m36.645s
user 3m59.252s
sys 0m21.026s
이번에는 LTO를 활성화했을 때의 시간이다.
$ make clean
$ ./scripts/config --enable CONFIG_LTO_MENU
$ time make -j8 vmlinux
real 1m24.774s
user 8m4.143s
sys 0m31.902s
컴파일에 시간을 더 많은 시간이 소요되는 것을 확인할 수 있다. 특히 최적화를 마지막에 하기 때문에 대기 시간이 늘어난 것을 볼 수 있다.
하지만 좀더 짜증날 수 있는 부분은 파일을 하나만 수정했을 때 다시 빌드를 하는 시간이다. LTO를 활성화하면 파일을 하나만 수정해도 최적화를 처음부터 다시해야한다.
LTO를 비활성화 했을때:
$ touch init/main.c
$ time make -j8 vmlinux
real 0m3.686s
user 0m5.803s
sys 0m1.819s
LTO를 활성화 했을때:
$ touch init/main.c
$ time make -j8 vmlinux
real 0m58.283s
user 5m6.089s
sys 0m12.732s
자, 여기까지는 LTO를 사용해서 크기를 약 22% 가량 줄이는 방법을 알아봤다. 하지만 이것도 아직 충분하지 않다. 다음섹션에서는 커널의 주요 기능들을 모듈로 분리해서 크기를 줄여본다.
'LWN.net' 카테고리의 다른 글
[LWN.net] Shrinking the kernel with link-time garbage collection (0) | 2021.11.05 |
---|---|
[LWN.net] bdflush() 시스템 호출의 삭제 (0) | 2021.08.23 |
댓글