[WWDC] Link fast: Improve build and launch times — Dynamic Linking
WWDC 2022 Link fast: Improve build and launch times 세션 Dynamic Linking 부분 정리
들어가기 전에
아래 포스팅에 이어
나머지 절반 내용인 Dynamic Linking 에 대해 포스팅합니다.
22년도 세션부터는 한글 자막이 달려있긴 하지만, 가끔씩 오역도 포함되어있는것 같아여,,,? 휴,,
그냥 가만히 영상만 봐서는 이해가 잘 안되기도 하고, 복기할때는 아무래도 글로 보는게 더 빠르니까 미래의 저를 위해 포스팅합니다요. 나중에 읽기 쉽도록 의역 및 부연 설명이 포함되어있어여
이번 글에는 page, segment, page fault, vm, page in, instruction, address, offset, dirty memory 등 굉장히 운영 체제 관련 키워드가 많이 나오는데요..! 그러니 마음의 준비 하시시고 ㄱㄱ!
What is dynamic linking?
사용하는 static library 가 늘어날 수록, 프로그램 빌드를 위한 static link 시간도 증가합니다.
위의 static library 모델에서 ar
을 ld
로 변경하고 (즉, 아카이브 도구 ar
을 -> 정적 링커 ld
로 변경), output library 를 executable binary 로 만든다면 어떻게 될까요?
이것이 90 년대의 dynamic library 의 시작이었습니다.
(참고로 [iOS] 빌드 결과로 보는 Static Framework 와 Dynamic Framework 에서 실제로 다이나믹 라이브러리의 output 이 별도의 executable binary 로 생성된 것을 확인할 수 있습니다.)
우리는 dynamic library 를 줄여서 dylibs 라고 부릅니다. 다른 플랫폼에서는 DSO 혹은 DLL 로 알려져있기도 하죠.
- DSO (Dynamic Shared Object) : 리눅스에서 동적 링크를 사용한 라이브러리. 확장자: .so
- DLL (Dynamic-Link Library) : 윈도우에서 동적 링크를 사용한 라이브러리. 확장자: .dll
출처 — 동적 링크, DLL, SO
동적 라이브러리의 장점
🤔 : 흠.. 동적 라이브러리가 뭔지 알겠는데.. 그래서 어쩌라고..? 확장성 측면에서 뭐가 좋은건데..?
핵심은 정적 링커(ld)가 정적 라이브러리(.a)와 동적 라이브러리(.dylib)의 링킹을 다르게 처리한다는 것입니다.
- 정적 라이브러리 : 최종 프로그램으로 코드를 복사
- 동적 라이브러리 : 동적 라이브러리에서 사용되는 심볼 이름과 런타임 시 라이브러리의 경로와 같은 일종의 약속을 기록
이렇듯 코드를 복사하지 않고 일종의 약속만을 기록하기 때문에, 프로그램의 정적 링크 시간은 링크하는 dylib 수와는 무관합니다 (코드 크기와 관련 있는 정적 라이브러리의 개수와는 비례하지만요!).
최종 프로그램은 단순히 자신의 코드와, 런타임에 필요한 동적 라이브러리 목록만을 포함할 뿐, 동적 라이브러리 코드의 복사본은 포함하지 않기 때문에 프로그램의 크기는 사용자의 통제 하에 있게 됩니다.
그리고 동적 라이브러리를 사용할 때 가상 메모리 시스템이 빛을 발합니다.
여러 프로세스에서 동일한 동적 라이브러리를 사용할 때, 가상 메모리 시스템은 모든 프로세스에서 해당 dylib 에 대한 동일한 물리적 RAM 페이지를 재사용합니다.
동적 라이브러리의 단점
이렇게 동적 라이브러리의 유래와 이것이 해결하는 문제점들을 알아봤는데요, 그렇다면 이러한 이점에 대한 비용(cost)은 무엇일까요?
- 먼저 동적 라이브러리를 통해 빌드 시간을 단축한 대신, 앱을 시작(launching)하는 시간은 더 느려집니다. 왜냐하면 앱을 시작(launching)하는 것은 더 이상 하나의 프로그램 파일만 로드하는 것이 아니라, 모든 dylib 를 로드 및 연결하는 과정도 필요하기 때문입니다. 즉, 링크 비용의 일부를 build 시간에서 launch 시간으로 미룬 것이라고 할 수 있습니다.
- 또한 동적 라이브러리 기반 프로그램은 더 많은 dirty page 를 가집니다. 정적 라이브러리의 경우, 링커는 모든 정적 라이브러리의 모든 global 을 메인 실행 파일(main executable)의 동일한 DATA 페이지에 다 같이 배치합니다. 하지만 dylibs 를 사용하면 각 라이브러리에 DATA 페이지가 있습니다.
- 마지막으로 런타임 링커인 dynamic linker (동적 링커) 가 필요합니다.
동적 링커 dyld
빌드 타임에 실행 파일에 기록된 약속을 기억하시나요? 이제 우리는 런타임에 라이브러리를 로드하기 위해 약속을 이행할 무언가가 필요합니다. 이것이 동적 링커인 dyld 의 목적입니다 (정적 링커는 ld, 동적 링커는 dyld).
동적 링커(dynamic linker) 는 라이브러리 내용을 영구적 기억 장치에서 RAM 으로 복사하고 점프 테이블을 채우고 포인터의 위치를 재조정함으로써 런타임 중에 실행파일에 필요한 공유 라이브러리를 로드하고 링크함
출처 — 동적 링커
dynamic linking 이 런타임에 어떻게 작동하는지 살펴볼까요?
executable binary 는 segment (일반적으로 적어도 TEXT
, DATA
, LinkedIT
) 로 나뉩니다. 세그먼트는 항상 OS 페이지 크기의 배수입니다.
각 세그먼트에는 서로 다른 권한이 있습니다. 예를 들어, TEXT
세그먼트에는 "execute" 권한이 있습니다. 이는 CPU가 페이지의 바이트를 machine code instructions 으로 처리할 수 있다는 것을 의미합니다.
__TEXT
세그먼트는 executable code 와 constant data 를 표함하는 read-only 영역입니다.일반적으로 컴파일러 툴은 모든 executable file 을 적어도 하나의 read-only
__TEXT
세그먼와 함께 생성합니다.segment 는 read-only 파일이기 때문에, kernel 은
__TEXT
세그먼트를 executable 에서 메모리로 직접 매핑할 수 있습니다. 세그먼트가 메모리로 매핑되면, 해당 내용에 관심이 있는 모든 프로세스 사이에서 공유될 수 있습니다 (이는 주로 프레임워크 및 공유 라이브러리에 해당됩니다).read-only 속성은
__TEXT
세그먼트를 구성하는 페이지를 백업 저장소에 저장할 필요가 없음을 의미합니다. 커널이 메모리를 확보해야할 경우, 하나 이상의__TEXT
페이지를 삭제하고, 필요할 때 디스크에서 다시 읽을 수 있습니다
런타임에 dyld 는 그림에 표시된 것처럼 실행 파일(executables)을 각 세그먼트의 권한에 맞게 메모리에 mmap()
해야 합니다.
세그먼트들은 페이지를 기준으로 크기 설정 및 정렬되기 때문에 (page sized and page aligned), 가상 메모리 시스템이 프로그램이나 dylib 파일을 VM 범위의 backing store 에 설정하는것이 간단합니다.
해당 페이지에 메모리 액세스가 있을 때까지 RAM 에는 아무것도 로드되지 않습니다. page fault 가 발생하면 VM 시스템이 파일의 적절한 하위 범위를 읽고 해당 콘텐츠로 필요한 RAM 페이지를 채웁니다.
그러나 매핑만으로는 충분하지 않습니다. 프로그램은 어떻게든 “연결 (wired up)”되거나 dylib에 바인딩되어야 합니다. 그리고 이를 위한 “fix up” 이라는 개념을 가지고 있습니다.
위의 다이어그램에서 프로그램의 포인터들이 설정된 것을 볼 수 있는데요, 최종적으로 선들은 프로그램이 사용하는 dylib 부분들로 향하고 있습니다.
그럼 fix-up 이 무엇인지에 대해 자세히 알아보겠습니다.
fixup
여기 mach-o 파일이 있습니다. TEXT
는 불변(immutable)하고, 코드 서명을 기반으로 하는 시스템에 있어야합니다.
여기서 malloc()
을 호출하는 함수가 있다면 어떻게 동작할까요? _malloc
의 상대 주소는 프로그램이 빌드될 때 알 수 없습니다.
여기서 실제로 발생하는 일은, static linker (ld) 가 malloc 이 dylib 에 있는 것을 보고, call site 를 변환하는 것입니다.
이제 call site 는 링커에 의해 합성된 stub 에 대한 호출이 되므로 빌드 시 상대 주소를 알 수 있습니다. 이는 BL
명령어(instruction)가 올바르게 형성될 수 있음을 의미하는데요, 여기서 stub 은 DATA
에서 포인터를 로드하고 해당 위치로 이동합니다.
The call site becomes a call to a stub synthesized by the linker in the same TEXT segment, so the relative address is known at build time, which means the BL instruction can be correctly formed. How that helps is that the stub loads a pointer from DATA and jumps to that location.
이렇게 런타임에서는 Text
를 변경할 필요가 없으며 dyld에 의해 DATA
만 변경됩니다.
사실 dyld 를 이해하는데 중요한 것은 dyld 에 의한 모든 fixup은 단지 dyld가 DATA
에 포인터를 설정하는 것에 불과하다는 것입니다.
LINKEDIT
의 어딘가에는 dyld 가 fixups 을 수행하는 데 필요한 정보가 있으며, fixup 에는 두 가지 종류가 있습니다.
fixup 타입 1 — rebase
fixup 의 첫번째 타입은 rebase 로, dylib 또는 앱이 자체적으로 내부를 가리키는 포인터를 갖는 경우에 필요합니다.
dyld 는 ASLR 을 통해 임의의 주소에서 dylib 을 로드하기 때문에 내부(interior) 포인터는 빌드 시에 설정할 수 없습니다.
ASLR (Address space layout randomization) — 메모리 상의 공격을 어렵게 하기 위해 스택이나 힙, 라이브러리 등의 주소를 랜덤으로 프로세스 주소 공간에 배치하여 실행할 때 마다 데이터 주소가 바뀌게 하는 기법
따라서 dyld 는 시작 (launch) 할 때, 이러한 포인터를 조정(adjust)하거나 “rebase”해야 합니다. 만약 dylib이 address zero 에 로드된다면, 디스크에서 이러한 포인터들은 그들의 target address를 포함합니다.
이제 LINKEDIT
은 각 rebase 의 위치만 기록하면 되고, dyld 는 dylib 의 실제 로드 주소를 각 rebase 위치에 추가하여 올바르게 수정할 수 있습니다.
fixup 타입 2— bind
fixups 의 두번째 타입은 bind 입니다.
바인드는 symbolic reference 로, symbol 의 이름을 기준으로 삼습니다.
‘“malloc” 함수에 대한 포인터’ 와 같은 예시를 생각하면 됩니다.
문자열 “_malloc” 은 실제로 LINKEDIT
에 저장되며, dyld 는 이 문자열을 사용하여 libSystem.dylib 의 exports trie 에서 malloc 의 실제 주소를 검색합니다. 그런 다음 dyld는 바인드에서 지정한 위치에 해당 값을 저장합니다.
chained fixup
2022 년 애플은 “chained fixups” 이라고 부르는 fixups 을 인코딩하는 새로운 방법을 소개합니다.
이것의 첫 번째 장점은 LINKEDIT
를 더 작게 만든다는 것입니다. 이것이 가능한 이유는 LINKEDIT
에서 모든 fixup 위치를 저장하는 대신, 각 DATA
페이지의 첫 번째 fixup 위치와 imported symbol 목록만을 저장하기 때문입니다.
나머지 정보는 최종적으로 fixup 이 설정될 위치인 DATA
세그먼트 자체에 인코딩됩니다.
이 새로운 형식이 chained fixups 이라는 이름을 갖게 된 것은 fixup 위치들이 함께 “연결”되어 있기 때문입니다.
LINKEDIT
은 첫 번째 fixup 이 있었던 위치만 알려주고, DATA
의 64-bit 포인터 위치에 있는 일부 비트는 다음 fixup 위치에 대한 오프셋을 포함합니다. DATA
에는 fixup 이 bind 인지 rebase 인지를 알려주는 비트들도 포함되어 있습니다. bind 면 나머지 비트가 symbol 의 인덱스가 되고, rebase 라면 나머지 비트는 image 내 target 에 대한 오프셋이 됩니다.
iOS 13.4 부터 chained fixup 에 대한 런타임 support 를 제공합니다.
chained fixup 포맷은 22 년 발표하는 dyld 의 새로운 기능인 page-in linking 을 가능하게 하는데요, 이를 이해하기 위해서는 dyld 가 어떻게 작동하는지부터 알아야합니다.
dyld는 메인 실행 파일(main executable) 로부터 시작합니다. 앱이라고 해보죠.
dyld 는 mach-o 를 파싱하여 종속 dylib, 즉 필요한 동적 라이브러리를 찾습니다. 그리고 찾은 dylib 에 대한 mmap()
을 수행 합니다. 그 다음 다시, 각각에 대해 재귀적으로 mach-o 구조를 파싱하고, 필요에 따라 추가 dylib 를 로드합니다.
모든 것이 로드되면 dyld는 필요한 모든 bind symbol 을 조회하고, fixup 을 수행할 때 해당 주소를 사용합니다.
마지막으로 모든 fixup이 완료되면 dyld는 initializer 를 bottom up 방식으로 실행합니다.
5년 전에 애플에서는 앱이 실행될 때마다 아래의 녹색 단계들이 매번 동일하다는 것을 깨닫고, 새로운 dyld 기술을 발표했습니다. 프로그램과 dylibs가 변경되지 않는 한, 녹색으로 표시된 모든 단계는 첫 번째 실행에서 캐시되고 후속 실행에서는 재사용 될 수 있습니다.
Recent dyld improvements : page-in linking
애플은 22 년 “page-in linking” 이라는 dyld 에 대한 추가적인 성능 개선 사항을 소개합니다.
dyld 가 실행(launch) 시에 모든 fixup 을 모든 dylib 에 적용하는 대신, 커널이 page-in 에서 fixup 을 DATA
페이지에 게으르게(lazily) 적용할 수 있습니다.
page-in : swap space 에 머물러 있는 내용을 다시 메모리로 불러들여 페이지 프레임을 제공하는 것
출처 — 운영체제 Page in/out, Swapping 등이 Page Table Entry 와 어떤 관계인가요?
mmap()
처리된 영역의 일부 페이지에서 일부 주소를 처음 사용하면, 커널이 해당 페이지를 읽도록 유발하는 경우가 항상 있었습니다. 그러나 이제 그것이 DATA
페이지라면, 커널은 해당 페이지에 필요한 fixup 도 적용할 것입니다.
It has always been the case that the first use of some address in some page of an
mmap()
ed region triggered the kernel to read in that page. But now, if it is a DATA page, the kernel will also apply the fixup that page needs.
애플은 10년이 넘도록 dyld 공유 캐시(dyld shared cache)에 있는 OS dylib 에 대한 특수한 page-in linking 케이스가 있었습니다. 올해는 이를 일반화하여 모든 사람이 사용 가능하도록 만들었습니다. 이 매커니즘은 dirty memory 와 launch time 을 줄입니다. 그렇다는 것은 DATA_CONST
페이지가 깨끗하다는 의미이고, 이는 TEXT
페이지와 마찬가지로 제거(evicted) 및 재생성(recreated)이 가능해서 메모리 압력(pressure)을 줄일 수 있다는 것을 의미합니다.
이 page-in linking 기능은 차기 iOS, macOS, watchOS 의 릴리즈에 포함될 예정입니다.
그러나 page-in linking 은 chained fixup 으로 빌드된 바이너리에서만 작동합니다. 그 이유는 chained fixup 을 사용하면 대부분 fixup 정보가 디스크의 DATA
세그먼트에 인코딩 되기 때문에, page-in 중에 커널에서 사용할 수 있기 때문입니다.
주의할 것은 dyld 가 이 매커니즘을 launch 중에만 사용한다는 것입니다. 나중에 dlopen()
된 dylib 들은 page-in linking 되지 못합니다. 이 경우 dyld는 기존 경로를 사용하여 fixup 을 dlopen 호출 중에 적용합니다.
이를 염두에 두고 dyld 워크플로우 다이어그램으로 돌아가 보겠습니다.
지금까지 5년 간 dyld 는 첫 실행에서 작업을 캐싱하고 나중의 실행에서 이를 재사용함으로써 아래 녹색 단계들을 최적화해 왔습니다. 그리고 2022년 부터는 dyld 가 실제로 fixups 작업을 수행하지 않고, 커널이 page in에서 게으르게 fixup 작업을 하도록 함으로써 “apply fixup” 단계를 최적화할 수 있습니다.
Dynamic linking best practices
이제 dyld 의 새로운 기능을 살펴보았으므로 Dynamic linking의 모범 사례에 대해 알아보겠습니다. dynamic link 성능 향상을 위해서는 무엇을 할 수 있을까요? 방금 살펴봤듯 dyld 는 이미 dynamic linking 의 대부분의 단계를 가속화했습니다.
우리가 제어할 수 있는 한가지는 dylib 의 개수입니다. 더 많은 dylib 가 있을수록, 이를 로드하기 위해 dyld 가 더 많은 작업을 해야합니다. 반대로 dylib 가 적을수록 dyld 가 수행해야 하는 작업은 줄어듭니다.
다음으로 살펴볼 수 있는 부분은, 항상 메인 이전에 실행되는 코드인 정적 이니셜라이저입니다. I/O 또는 네트워킹과 같이 몇 밀리초 이상 걸릴 수 있는 작업은 절대로 정적 이니셜라이저에서 수행해서는 안됩니다.
세상은 점점 복잡해지고 사용자들은 더 많은 기능을 원합니다. 따라서 라이브러리를 사용하여 모든 기능을 관리하는 것이 합리적입니다. 여기서의 목표는 동적 라이브러리와 정적 라이브러리 사이의 최적의 지점을 찾는 것입니다.
정적 라이브러리가 너무 많으면, 반복적인 빌드 및 디버깅 주기가 느려집니다. 반면 동적 라이브러리가 너무 많으면, 실행(launch) 시간이 느려지고 고객이 이를 알아차립니다. 하지만 올해 ld64 의 속도를 높였기 때문에, 더 많은 정적 라이브러리 또는 더 많은 소스 파일을 앱에서 직접 사용하면서, 여전히 동일한 시간 내에 빌드할 수 있습니다. 따라서 정적 / 동적 라이브러리 사이의 최적의 지점이 변경되었을 수 있습니다.
마지막으로 설치 기반(installed base)에서 작동하는 경우, 새로운 deployment target 으로 업데이트하게 되면 도구들이 chained fixup을 생성하여 바이너리를 더 작게 만들고 실행 시간을 단축할 수 있습니다.
New tools
마지막으로 모두가 알아줬으면 하는 것은 linking process의 내부를 엿보는 데 도움이 되는 두 가지 새로운 툴입니다.
dyld_usage
첫번째 툴은 dyld_usage
입니다. 이것을 사용해 dyld 가 하는 일을 추적할 수 있습니다. 추적에 Instruments 앱과 동일한 매커니즘을 사용합니다.
이 툴은 macOS 에만 있지만, 시뮬레이터에서 실행 중인 앱이나 Mac Catalyst 용으로 빌드된 앱을 추적하는 데 사용할 수 있습니다. macOS 13 부터 사용 가능합니다.
기존의 fs_usage
와 유사합니다.
아래 사진은 macOS에서 TextEdit에 대해 실행된 예제입니다.
맨 위의 몇 줄에서 알 수 있듯이 실행하는데는 총 15ms 가 걸렸지만, page-in linking 덕분에 fixup 에는 1ms 밖에 걸리지 않았습니다.
이제 대부분의 시간은 정적 이니셜라이저에서 소요됩니다.
dyld_info
두번째 툴은 dyld_info
입니다. 이를 통해 디스크와 현재 dyld 캐시 모두에서 mach-o 파일 및 dylib 과 같은 바이너리를 검사할 수 있습니다. macOS 12부터 사용 가능합니다.
dyld_info
에는 여러 가지 옵션이 있지만, fixups 및 exports 을 보는 방법을 보여드리겠습니다.
우선 -fixup
옵션은 dyld 가 처리할 모든 fixup 위치와 해당 target 을 표시합니다. 파일이 이전 스타일의 fixup 인지, 아니면 새로운 chianed fixup 인지 관계없이 ouput 은 동일합니다.
그 다음 -exports
옵션은 dylib 에 있는 모든 exported symbols 과, dylib 의 시작부터 각 symbol 에 대한 오프셋을 표시합니다.
이 경우 dyld 캐시에 있는 dylib 인 Foundation.framework
에 대한 정보를 보여주고 있습니다. 디스크에 파일이 없지만 dyld_info
툴은 dyld 와 동일한 코드를 사용하므로 찾을 수 있습니다.
마무리
지난 포스팅에 이어 정적 라이브러리와 동적 라이브러리의 역사와 trade off 에 대해 이해했으므로, 앱이 수행하는 작업을 검토하고 그 최적의 지점을 찾았는지 확인해야 합니다.
대형 앱에서 링크하는 데 시간이 많이 걸린다는 것을 알았다면, 더 빠른 링커가 있는 Xcode 14를 사용해 보세요.
정적 링크의 속도를 더 높이고 싶다면, [WWDC] Link fast: Improve build and launch times — Static Linking에서 설명했던 세 가지 링커 옵션을 살펴보고 여러분의 빌드에서 유의미한지, 링크 시간 개선에 도움이 되는지 확인하세요.
마지막으로 iOS 13.4 이상으로 앱 및 임베디드 프레임워크를 빌드해서 chained fixups 을 활성화할 수도 있습니다. 그런 다음 iOS 16 에서 앱이 더 작고 더 빨리 실행되는지 확인하세요.