[WWDC] Link 속도를 높여보자 (1) - static link
Linking 과정에서 일어나는 일에대해 알아봄으로써 어떻게 Linking 속도를 높일수 있는지 배워봅시다.
Linking 이란?
우리가 작성한 코드와 library나 framework와 같은 외부 의존성을 함께 사용하기 위해선 linker가 필요합니다. linking은 크게 두 가지 종류로 나뉘는데, 먼저 static linking은 build time에 일어나는데 build time과 app size에 영향을 주게 됩니다. 반면 dynamic linking은 앱이 실행될 때 일어나며, launch time에 영향을 주게 됩니다.
Static Linking
Static linking에 대해 더 자세히 알아보기 전에 과거로 돌아가 history를 살펴보도록 합시다.
옛날 옛적 프로그램은 매우 단순했습니다. 하나의 소스파일만이 있었고 그 소스파일을 컴파일해 실행가능한 프로그램을 만들기만 하면 되었죠. 하지만 시간이 지나면서 모든 소스코드를 한 파일에 몰아넣기엔 어렵게 되었고, 여러 파일이 필요하게 됩니다. 여러개의 소스파일은 어떻게 빌드해야 할까요?
소스파일을 나누고자 하는 시도는 단지 파일이 거대해 지는 것 뿐만이 아니라 모든 함수를 다시 컴파일 하는 것을 피하기 위해서 이기도 했습니다. 따라서 컴파일러를 두 부분으로 나누게 됩니다.
첫 번째 부분은 소스코드를 relocatable 한 object 라는 중간 파일로 컴파일 합니다. 그리고 두 번째 부분에서는 첫 번째의 결과물인 오브젝트 파일을 읽어 executable로 만듭니다. 그리고 여기서 두 번째 부분을 ld 혹은 static linker라고 부릅니다.
소프트웨어가 발전함에 따라 사람들은 많은 .o(오브젝트) 파일을 만들어 사용했고, 점차 번거로워 지기 시작했습니다. 이 때 누군가가 “이 많은 오브젝트 파일들을 라이브러리로 패키징하는게 낫지 않을까?” 하는 생각을 떠올렸는데, 당시엔 ‘ar’ 이라고 하는 파일들을 bundling 하는 아카이빙 도구가 표준처럼 사용되던 시기였습니다. 그러다 보니 workflow가 다음과 같이 변하게 되는데, 오브제젝트 파일들을 ar로 아카이브 하는 과정이 추가된 것입니다. 그리고 링커는 아카이브 파일에서 오브젝트 파일을 직접 읽어낼 수 있도록 개선되었습니다.
이 방법은 공통의 코드를 공유하는 방법의 놀라운 발전이었습니다. 당시에 단순하게 archive혹은 library라도 불리우던 것이 지금은 static libarary라도 불리게 된것입니다.
하지만 라이브러리에 있는 수천개의 함수들이 그대로 복사되어 들어오면서 프로그램의 크기 또한 비대해졌습니다. 실제로는 그 많은 함수들 중 단 몇개만을 사용하는데 말이지요. 따라서 최적화 과정이 추가되게 됩니다. linker는 static library에 모든 오브젝트 파일을 끌어오지 않고 undefined symbol을 resolve 하는 경우에만 끌어오도록 하는 것입니다. 이는 개발자들이 C의 거대한 표준 라이브러리인 libc.a 를 동일하게 link하면서도 프로그램에 실제 필요한 부분만 사용할 수 있게 해주었습니다. 그리고 이 모델은 지금까지도 사용되고 있습니다.
하지만 앞서 설명한 ‘선택적 로딩’은 개발자들을 햇갈리게 할 때가 있어서, 분명한 이해를 돕기 위해 간단한 예시를 들어보겠습니다.
main.c 파일에는 foo 함수를 호출하는 main 함수가 있습니다. foo.c 파일에는 bar 함수를 호출하는 foo 함수가 있습니다. bar.c 에는 bar 함수의 구현부가 있으며 사용되지 않는 함수인 unused 함수도 가지고 있습니다. 마지막으로 baz.c 에는 undef 함수를 호출하는 baz 함수가 있습니다.
이제 이들 각각을 오브젝트 파일로 컴파일 해보겠습니다. foo, bar, undef 에는 회색 표시가 없네요. undefined symbol이기 때문입니다. undefinded symbol은 symbol의 정의 아닌 사용(참조)을 뜻합니다.
이제 bar.o 와 baz.o 를 static library로 묶고, 나머지 두개의 오브젝트 파일과 static library를 link 해보겠습니다.
linker는 command line order에 따라 동작합니다. 먼저 main.o 찾아 로드하고 main 함수의 정의를 찾습니다. symbol table에 올라가 있는게 보이시나요? 이때 main 함수만 찾는게 아니라 main 함수가 undefined 상태인 foo를 가지고 있다는 것 또한 찾게됩니다.
linker는 command line의 다음 파일을 파싱하는데 여기에선 foo.o군요. foo.o는 foo함수의 정의를 가지고 있고 이는 foo가 더이상 undefined 상태가 아니게 된다는 것입니다. 여기서 멈추지 않고 foo.o를 로딩하면서 bar라는 undefined symbol이 추가됩니다.
이제 command line에 있는 모든 오브젝트 파일들이 로드되었습니다. linker는 아직 남아있는 undefined symbol이 있는지 확인하는데 이 상황에선 bar가 되겠군요. 이제 linker는 남아있는 undefined symbol의 참조를 찾기 위해 command line의 라이브러리 들을 살펴보기 시작합니다. static library에서 bar symbol을 정의하고 있는 bar.o를 찾을 수 있군요. 이제 linker는 archive에서 bar.o를 로드합니다.
이 시점에서 더이상 남아있는 undefined symbol이 없으니 라이브러리를 읽는 것을 중단하고 다음 단계로 넘어갑니다. 프로그램에 포함될 모든 함수와 데이터에 주소를 할당하는 것입니다. 그리고 나서 모든 함수와 데이터들을 output 파일로 복사합니다.
드디어 최종 결과물인 프로그램이 나왔습니다. baz.o는 분명 static library에 있지만 프로그램에는 포함되지 않았습니다. linker가 static library를 선택적으로 로드했기 때문입니다.
이제 static linking과 static library에 대해 알았으니 ld64라고 불리우는 애플의 static linker의 개선점에 대해 알아보겠습니다.
ld64 최적화
애플은 많은 요구에 따라 ld64 최적화 작업을 진행했고, linker는 최대 2배의 속도를 내게 될것이라고 합니다. linker의 작업을 병렬적으로 진행할 수 있는 부분들을 찾아내었고 여러개의 코어를 활용하는 것으로 linking속도를 개선한 것입니다. 속도를 높이기 위해 개선된 부분은 다음을 포함합니다.
- input파일의 내용(content)을 output파일로 복사
- LINKEDIT의 서로 다른 부분을 병렬적으로 빌드
- UUID 계산과 코드 서명 해싱을 병렬적으로 처리
- exports-trie builder 알고리즘 개선
- UUID(SHA256) 계산 가속
- crypto 라이브러리의 최신 버전을 채택하여 하드웨어 가속을 향상
linker 속도 끌어올리기
linker의 성능을 향상시키는 작업을 하는 과정에서 몇몇 앱들의 configuration이 link time에 영향을 준다는 것을 알게 되었다고 합니다. 어떻게 어플리케이션 개발자인 우리가 link time을 개선시킬 수 있을지 알아봅시다. 총 다섯 가지 입니다. 언제 static library를 사용해야 하는지, link time에 영향을 주는 세가지 옵션(-all_load or force_load, -no_exported_symbols, -no_deduplicate), 몇 가지 static linking 동작에 대해서 입니다.
static library는 언제 사용해야 할까?
만약 static library로 빌드된 소스 파일을 사용한다면 빌드 타임이 늦춰지는 것을 경험하게 될 것입니다. 왜냐하면 파일에 변경이 일어난 후(컴파일 되고 난 후) content table을 포함한 모든 static library는 rebuild되어야 하기 때문이죠. 따라서 static library는 변경이 잘 일어나지 않는 안정된 코드에 사용하는 것이 적합하다고 할 수 있습니다. 활발하게 개발중인 코드를 static library로 부터 빼내어 build time을 줄이는 것을 고려해 보시기 바랍니다.
앞서서 우리는 선택적 로딩에 대해 살펴본바 있습니다. 선택적 로딩은 앱의 사이즈를 줄여주지만 linker의 속도를 늦춘다는 단점이 있습니다. 빌드를 반복가능하게 만들고, 전통적인 static library semantic을 따르기 위해 linker는 정해진 순서대로 프로세싱 해야 하기 때문입니다. 이는 ld64의 병렬화를 통한 성능 개선의 일부는 static library에 적용될 수 없다는 것을 의미합니다. 하지만 이런 역사적 동작을 따를 필요가 없다면 linker option을 사용해 빌드 속도를 높일 수 있습니다.
-all_load
그중 하나가 -all_load 입니다. 이 옵션은 linker가 모든 오브젝트 파일을 맹목적으로 로드하게 합니다. 이 옵션은 라이브러리의 대부분을 사용하게 될 때 유용합니다. -all_load 옵션 사용은 linker가 static library의 모든 컨텐트를 병렬적으로 파싱하는 것을 허용하게 합니다.
하지만 만약 동일한 symbol을 구현하는 다수의 static library를 사용하고, static library의 command line order에 의존하는 트릭을 사용할 경우 이 옵션 사용은 적합하지 않습니다. 왜냐하면 linker는 모든 구현을 로드하고, 일반적인 static linking mode에서 발견한 symbol semantic을 반드시 얻는 것이 아니기 때문입니다.
또 다른 단점으로는 사용되지 않는 코드가 프로그램에 들어갈 것이기 때문에 프로그램이 커지게 됩니다. 이를 보안하기 위해 -dead_strip옵션을 사용할 수도 있습니다. 이 옵션은 linker로 하여금 도달 불가능한 코드를 제거하게 합니다. 속도가 빠른 dead stripping 알고리즘을 이용해 output파일의 크기를 줄임으로써 비용의 대가를 스스로 지불하게 하는 것입니다. 만약 -all_load와 -dead_strip 옵션을 사용하고자 한다면 옵션이 추가될 때와 아닐때의 linking time을 비교해보시기 바랍니다.