https://developer.apple.com/videos/play/wwdc2024/10217/
해당 포스트는 위 영상을 기반으로 개인적으로 메모/정리한 글입니다.
개요
Swift 가 추상화와 성능의 균형을 유지하는 방법을 알아보자.
어떤 성능 요소를 고려해야 하며 Swift 옵티마이저가 어떤 영향을 끼치는지 배워보자.
성능에 대한 절충안을 위한 Swift 의 다양한 기능과 구현 방법을 알아보자.
What is performance?
퍼포먼스는 하나의 수치로 나타낼 수 있는 게 아니라 다차원적이고 상황에 따라 다른 것이다.
보통 우리가 퍼포먼스를 신경쓸 때는 거시적인 문제 때문임. Latency, 발열, UI 프리징 등등. 우리는 보통 이런 문제를 조사하기 위해 instrunments 같은 툴을 쓸 것임. (Top-down 접근) 그리고 대부분의 경우는 low-level 퍼포먼스는 파악할 필요도 없이 단순한 알고리즘 개선 등을 통해 이런 문제를 해결할 것임.
하지만 어떤 때는 low-level 퍼포먼스를 파고 들 필요가 생긴다. (Bottom-up 접근) 단순한 알고리즘 개선으로 해결할 수 없는 상황들. (ex) 이 함수가 왜 이렇게 오래 시간이 걸리지?
Swift Optimizer 는 알아서 엄청난 퍼포먼스 향상을 해주지만 이것을 방해할 요소가 있을 수도 있음. Swift Optiomizer 를 신뢰하되, 지속적인 프로파일링을 하라. 이것을 개발 주기의 한 단계로 넣어라.
Low-level principles
Low-level 퍼포먼스 고려점: Function calls, Memory layout, Memory allocation, Value copying. 크게 이 4가지 중 하나가 잘못되면 문제가 생김. 하나씩 자세히 살펴볼 것임.
Function calls
위 4요소가 Cost 에 관련된 것. 하나씩 살펴보자.
함수 Arguments 부분
이런 retain 이나 copy 는 그냥 우리가 무시하고 넘어가기 쉽지만 실제로는 이루어지고 있음.
함수 호출 시의 Call dispatch
Static(direct) dispatch vs Dynamic(indirect, virtual) dispatch: Static 디스패치는 정확한 함수 정의를 컴파일러가 미리 알 수 있기 때문에 컴파일 타임에 많은 최적화가 가능하다(inlining, generic specialization, …). 반면 Dynamic 디스패치는 다형성/추상성을 가능하게 하지만 컴파일 타임에는 최적화가 불가능하다.
Swift 에서의 Dynamic dispatch 는 다음을 호출할 때 발생한다
- opaque function
- overridable class methods
- protocol requirements
- Objective-C or virtual C++ methods
Local State 를 위한 메모리 할당
함수가 실행되기 위해서는 메모리 할당이 필요함. 일반적인 sync 함수라면 stack 에 할당된다. CallFrame.
Memory allocation
- Global memory
- 프로그램 로드 시 allocated & initialized, 프로그램 실행 내내 고정된 메모리 양 유지
- (ex) Global scope 에서 let, var 선언하거나 static 프로퍼티 등
- Stack memory
- 오직 current scope 에서만 메모리가 유지된다
- (ex) 로컬 let, var 또는 함수 CallFrame, temp 변수 등
- Heap memory
- flexible. 임의의 시간에 할당되고 임의의 시간에 해제됨. 따라서 다른 메모리에 비해 expensive
- (ex) class, actor 인스턴스.
- Reference counting을 통해 메모리 할당 수명이 관리된다 (힙 메모리는 여러 곳에서 참조하며 공동 소유권을 가질 수 있음) retain & release.
Memory layout
메모리 레이아웃: Swift 가 메모리를 사용하여 값을 어떻게 저장하는가의 방법
High-level 에서의 “value” 저장 개념은 다음과 같다.
Low-level 에서의 “value” 저장 개념은 다음과 같다.
예시로 알아보자
사실 여기서 Array 라는 건 struct 다. (public struct Array<Element>)
참고: struct 의 inline representation 은 해당 struct 에 속하는 stored properties 의 inline representation 와 같다. (== internal class __ContiguousArrayStorageBase { … })
따라서 위 예제에서 CallFrame 은, 실제로는 array 라는 이름의 pointer를 가진다고 보면 된다.
struct, tuple, enum 등은 모두 inline storage 를 사용한다. (위 Array Struct 예시처럼) 반면 class, actor 등은 Out-of-line storage 를 사용한다. 이것들의 컨테이너는 단지 포인터만 저장하고 있다. 아래에서 다루기도 할텐데, 값 copy 참조 copy 개념을 생각해보면 이해가 될 것이다.
Value copying
Value 의 representation 을 관리할 책임을 가지는 것을, Value 의 Ownership 을 가지고 있다고 표현한다.
Swift 에서 값/변수를 사용할 때는 ownership system 과 상호작용하게 된다. 세 가지 상호작용 종류가 있음; Consume, Mutate, Borrow.
<1> Consume values: Value 를 저장공간에 Assign
var array = [1.0, 2.0] // consume
var array2 = array // copying -> retain 발생
// 일반적으로 위 상황에서 array 가 다시 쓰이지 않는다면 컴파일러가 알아서 최적화 함.
// array 소유권을 array2 에게 그냥 넘기는 것
// Noncopyable 도입으로 이제 이걸 명시적으로도 나타낼 수 있음
var array2 = consume array
<2> Mutate values: 임시적으로 소유권을 넘기는 것. inout 파라미터 생각하면 됨
var array = [1.0, 2.0]
array.append(3.0)
<3> Borrow values: Read-only, 일반 함수 파라미터. Consume 이나 Mutate 금지!
var array = [1.0, 2.0]
print(array) // borrow
// 그러나 값 타입이 아닌 참조 타입에서 Swift 는 Borrow 대신 방어적으로 Copy를 하기도 한다
// 예를 들면, 아래와 같은 상황
func makeArray(object: MyClass) {
object.array = [1.0, 2.0]
print(object.array)
}
// object 의 값이 다른 곳에서 동시에 mutate 될 수도 있기 때문에 방어적으로 Copy를 하는 것.
// 이런 것이 최적화와 연결된다.
이 부분 컨셉은 아래 세션에서도 다룸
2024.08.08 - [WWDC/2024] - Consume noncopyable types in Swift
결국 Swift 의 Copy 는 대상이 어떤 inline representation 을 가지는 지에 따라 다른 매커니즘을 갖는다.
- Value 타입을 Copy하면: 값의 inline representation 자체를 카피하는 것. 새로운 ownership이 생성된다. 값의 모든 stored properties 를 전체 카피한다.
- Reference 타입을 Copy하면: 객체의 reference 를 retain 하는 것(reference count +1). reference 의 ownership을 카피한다.
여기서 생각해볼 수 있는 Trade-offs
- inline storage(Struct 등)
- heap allocation 피할 것
- 내부 프로퍼티가 많아질 수록 Copy에 많은 비용이 든다 —> 따라서 많은 내부 프로퍼티를 가진 모델을 Struct 로 만들면 Copy가 발생할 때마다 메모리를 많이 쓴다.
- Out-of-line storage(Class 등)
- 기본적으로 Reference semantic임. 즉, Value semantic 을 권장하는 Swift 의 방침과 다름. 값을 복사할 경우 새 값을 변경하면 기존 값에 영향이 갈 수 있다는 것임.
- 추천: Value semantic + copy-on-write 로 사용하기. 방법은 class 를 struct 로 감싸고, mutation 이 발생할 때 object 를 따로 copy 하는 방식이다. Swift 의 많은 기본 자료구조가 이 테크닉을 쓰고 있음. Array, Dictionary, String, …
Putting it together
이제 위 원칙들을 기반으로 한 Swift 의 high-level features 에 대해 알아보자.
Dynamically-sized types
Swift type 의 크기가 런타임에 결정되는 케이스들이 있다.
<Case 1> 향후 업데이트에서 Stored Property 가 추가 될지도 모르는 타입들 (ex: URL)
<Case 2> Generic type parameters
예외: 제네릭 타입 파라미터에 class constraint 를 걸 경우, 항상 address 는 메모리를 가리킬 포인터라는 것을 알기 때문에 미리 memory layout 을 알 수 있다. 따라서 효율적임!
Dynamic-sized type (ex: URL) 이라도 글로벌 변수라면 컴파일 타임에 고정된 메모리를 가진다.
이와 유사하게, 함수의 로컬 변수도 마찬가지로 고정된 사이즈를 가짐. CallFrame이 항상 고정된 사이즈를 가지기 때문임.
(+) URL은 dynamic sized type 이기 때문에, 실제 런타임에서 함수가 호출되고 URL 변수에 접근하게 되면 Stack은 다음과 같이 쌓이게 된다.
Async functions
Async function 을 위한 구현; <1> 로컬 State를 별도의 Stack 에 유지함. <2> 함수를 실제 런타임에는 부분 함수들(partial functions)로 쪼갠다. await 로 suspensions 될 때마다 쪼개짐.
Closures
클로저는 function type. Swift 에서 function value 는 function pointer + context pointer 다. 함수를 호출하는 것은 곧 함수 포인터를 호출하면서 콘텍스트 포인터를 argument 로 넘기는 것임.
클로저가 non-escaping 일 때는 함수 호출이 끝나면 더 이상 사용되지 않음이 확실하다. 따라서 콘텍스트는 struct 형태로 Stack에 할당된다. 클로저 내부에서 캡쳐하는 변수들은 원래 변수의 pointer만 캡쳐하는 형태임.
클로저가 escaping 일 때는 함수 호출이 끝난 뒤에도 클로저가 사용될 수 있다. 따라서 콘텍스트는 class 형태로 Heap에 할당되며 reatin & release 로 관리되어야 한다. 또한 이 경우는 클로저 내부에서 캡쳐하는 변수들도 Heap에 할당되고 reatin 된다. 포인터만 캡쳐하면 원 객체가 release 되어버릴 위험이 있기 때문이다. (그래서 우리가 이럴 때는 weak 로 캡쳐해야 하는 것)
Generics
프로토콜의 경우는 런타임에 함수 포인터들의 테이블로 표현(represented)된다 → Witness table
<프로토콜을 사용하는 함수 케이스1> 제네릭 함수
<프로포콜을 사용하는 함수 케이스2> any 프로토콜 파라미터
DataModel 프로토콜의 inline representation 은 값을 위한 저장소(OpaqueValueStorage)와 타입 정보(TypeMetadata) 필드를 가진다.
이때 AnyDataModel 는 고정된 사이즈의 타입이어야 한다. 하지만 어떤 타입이 AnyDataModel 을 구현하냐에 따라 당연히 Value의 크기가 달라질 수 있다. 따라서 Value를 저장하는 스토리지에서는 버퍼를 가지며, 저장할 값이 버퍼보다 큰 경우에는 heap 에 저장 후 포인터만 버퍼에 저장한다. 저장할 값이 버퍼에 들어갈 수 있다면 그냥 inline으로 저장한다.
<다시 정리하기; 두 케이스의 차이>
함수1 - 제네릭 함수는 동일한 타입의 [Model] 리스트를 받는다. 그리고 타입 정보는 top-level argument 로 함수에 1번만 전달된다. 옵티마이저를 통해 이 함수는 상황에 따라 추상화가 제거된 버전으로 최적화될 수 있음.(미리 어떤 타입으로 호출하는지를 알 수 있는 케이스)
함수2 - any 프로토콜 타입 리스트를 받는 함수는 [Model] 리스트에 각각 다른 타입이 들어있을 수 있다. 이 경우 옵티마이저가 최적화하기 어려움. 컴파일러가 미리 어떤 타입들이 들어올지를 추론해야 하기 때문이다. → “컴파일러의 최적화 도움을 받기는 어렵지만, 그래도 이것 때문에 프로토콜 타입을 쓰지 말아야겠다고 생각하지 마세요. 추상화는 여전히 강력한 툴입니다. 단, 코스트가 든다는 것을 염두에 두세요.”
'WWDC > 2024' 카테고리의 다른 글
Discover media performance metrics in AVFoundation (0) | 2024.08.08 |
---|---|
Analyze heap memory (0) | 2024.08.08 |
Demystify explicitly built modules (추천세션!) (0) | 2024.08.08 |
Migrate your app to Swift 6 (추천세션!) (0) | 2024.08.08 |
Consume noncopyable types in Swift (0) | 2024.08.08 |