본문 바로가기
WWDC/2024

Explore Swift performance

by 토끼찌짐 2024. 8. 8.

https://developer.apple.com/videos/play/wwdc2024/10217/

 

Explore Swift performance - WWDC24 - Videos - Apple Developer

Discover how Swift balances abstraction and performance. Learn what elements of performance to consider and how the Swift optimizer...

developer.apple.com

해당 포스트는 위 영상을 기반으로 개인적으로 메모/정리한 글입니다.

개요

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.

예를 들면, 오른쪽 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”는 메모리를 가리키는 이름이다. 메모리에 실제로 [1.0, 2.0]이 어떻게 배열되는지를 살펴보자.

예시로 알아보자

사실 여기서 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

 

Consume noncopyable types in Swift

https://developer.apple.com/videos/play/wwdc2024/10170/ Consume noncopyable types in Swift - WWDC24 - Videos - Apple DeveloperGet started with noncopyable types in Swift. Discover what copying means in Swift, when you might want to use a noncopyable type,

wlaxhrl.tistory.com

 

결국 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)

이게 무슨 말이냐면, URL 타입 내부에 다음 업데이트에서는 새로운 stored property 가 추가될 수도 있다는 말임
컴파일 타임에는 제대로 된 Size, Offset 을 알지 못한다.
런타임에 제대로 된 Size, Offset 을 동적으로 알게 된다.

 

<Case 2> Generic type parameters

컴파일 타임에 미리 memory layout 이 어떻게 되는지를 알 수 없다. 런타임에 결정 됨

예외: 제네릭 타입 파라미터에 class constraint 를 걸 경우, 항상 address 는 메모리를 가리킬 포인터라는 것을 알기 때문에 미리 memory layout 을 알 수 있다. 따라서 효율적임!

Dynamic-sized type (ex: URL) 이라도 글로벌 변수라면 컴파일 타임에 고정된 메모리를 가진다.

Pointer Type!

이와 유사하게, 함수의 로컬 변수도 마찬가지로 고정된 사이즈를 가짐. CallFrame이 항상 고정된 사이즈를 가지기 때문임.

CallFrame은 URL을 가리키는 “포인터”만 가지고 있다.

(+) 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> 제네릭 함수

제네릭 함수에서는, 타입정보와 WitnessTable이 hidden parameter 로 전달된다

<프로포콜을 사용하는 함수 케이스2> any 프로토콜 파라미터

이런 케이스에서는 array 의 각 요소들이 각각 다른 타입이 될 수 있다!

DataModel 프로토콜의 inline representation 은 값을 위한 저장소(OpaqueValueStorage)와 타입 정보(TypeMetadata) 필드를 가진다.

이때 AnyDataModel 는 고정된 사이즈의 타입이어야 한다. 하지만 어떤 타입이 AnyDataModel 을 구현하냐에 따라 당연히 Value의 크기가 달라질 수 있다. 따라서 Value를 저장하는 스토리지에서는 버퍼를 가지며, 저장할 값이 버퍼보다 큰 경우에는 heap 에 저장 후 포인터만 버퍼에 저장한다. 저장할 값이 버퍼에 들어갈 수 있다면 그냥 inline으로 저장한다.

 

<다시 정리하기; 두 케이스의 차이>

앞으로 두 함수를 각각 함수1, 함수2로 명명한다

함수1 - 제네릭 함수는 동일한 타입의 [Model] 리스트를 받는다. 그리고 타입 정보는 top-level argument 로 함수에 1번만 전달된다. 옵티마이저를 통해 이 함수는 상황에 따라 추상화가 제거된 버전으로 최적화될 수 있음.(미리 어떤 타입으로 호출하는지를 알 수 있는 케이스)

함수2 - any 프로토콜 타입 리스트를 받는 함수는 [Model] 리스트에 각각 다른 타입이 들어있을 수 있다. 이 경우 옵티마이저가 최적화하기 어려움. 컴파일러가 미리 어떤 타입들이 들어올지를 추론해야 하기 때문이다. → “컴파일러의 최적화 도움을 받기는 어렵지만, 그래도 이것 때문에 프로토콜 타입을 쓰지 말아야겠다고 생각하지 마세요. 추상화는 여전히 강력한 툴입니다. 단, 코스트가 든다는 것을 염두에 두세요.”