https://developer.apple.com/videos/play/wwdc2016/416/
Dimensions of Performance
- 그래프가 왼쪽에 있을 수록 성능이 좋다!
- Allocation
- Stack
- 스택 포인터를 이용하여 빠르게 allocate/deallocate 가능
- Heap
- Advanced data structure
- 동적 lifetime으로 메모리 할당 가능 (스택을 불가능)
- 할당을 위해 메모리에서 사용하고 있지 않은 블록을 찾는 과정 필요
- deallocate를 위해 메모리의 적절한 위치에 블록을 reinsert 해야함
- Thread Safety를 위한 오버헤드 발생
- 여러 스레드가 동시에 힙에 접근 할 수 있다. 따라서, lock 또는 다른 동기화 메커니즘을 통해 무결성을 보호해야 한다.
- 스택은 스레드 별로 따로 생성 + 관리되기 때문에 이러한 이슈가 없다.
- Stack
Struct
- Value Semantics
- 값 타입
- 복사 발생
Class
- Reference semantics
- 복사 X, 주소를 공유 ⇒ 의도하지 않은 공유가 발생할 수 있다.
- Struct와 다르게 메모리(Heap)에 2 word를 추가로 배정한다.
Struct vs Class
- Class는 힙 할당 → 오버헤드가 더 필요하다.
- Class는 Identity와 간접 저장 같은 몇가지 강력한 특징이 있다.
- 그러나 추상화에 이러한 특성을 활용할 필요가 없다면 Struct를 사용하는 것이 바람직하다.
코드에서의 성능 향상 방법
- 위처럼 풍선 Image를 만들기 위한 캐싱 처리를 할 때 chache 딕셔너리의 키의 타입을 String으로 지정한다면 String 자체는 Struct이지만 값이 길어지면 Heap에 저장되는 특성이 있기 때문에 Heap 할당이 발생한다.
- 개선 방법 → String 대신 Struct를 사용한다.
- Struct는 스택에 저장되기 때문에 오버헤드가 작다.
Reference Counting
- Swift는 힙의 모든 인스턴스에 대해 Reference count를 관리하며 이것은 인스턴스 자체에 유지한다.
- Int값으로 관리하며 참조가 발생하면 +1 감소하면 -1 로 처리한다.
- 0이 되면 해당 인스턴스를 아무도 참조하지 않고 있다는 의미로 메모리에서 해제한다.
- 단순히 정수를 증가, 감소 시키는 것 뿐만 아니라 다음과 같은 작업도 발생한다.
- 증가 및 감소를 실행하기 위한 간접 참조 발생
- Thread safety overhead
- 여러 스레드가 힙 인스턴스에 참조를 동시에 추가하거나 줄일 수 있다. → reference count를 atomic하게 관리해야한다.
- 클래스의 인스턴스에 대한 참조는 위와 같이 메모리 구조가 형성된다.
Struct 에서의 Reference counting
- Struct는 기본적으로는 Stack에 저장되기 때문에 Reference counting이 발생하지 않는다.
- 하지만, 아래와 같이 복잡한 Struct에서는 상황이 다르다.
- Struct가 프로퍼티로 참조 타입을 가지게 된다면 이때는 당연히 각각의 프로퍼티에 대한 힙 할당이 발생하게 되고 Reference counting을 위한 retain과 release가 각각 발생하게 된다.
- 따라서, Struct는 프로퍼티로 참조 타입을 많이 가질수록 Reference Counting Overhead는 크게 증가하게 된다. ⇒ 이럴 때는 그냥 Class를 사용하거나 참조 타입 대신 값 타입을 사용하도록 수정해야 한다.
Struct에서의 참조 타입 제거 예시
- 이 예시에서 3개의 프로퍼티는 전부 힙 할당이 발생한다. → 오버헤드가 크다.
- 일부를 값 타입으로 교체하자!
- 구조체인 UUID와 enum인 MimeType(별도 구현)을 사용하여 참조 타입의 개수를 1개로 줄였다.
Method Dispatch
Static Dispatch
- 컴파일 타임에 실행할 구현을 결정한다. → 런타임에는 적절한 구현으로 바로 이동 가능
- 컴파일러가 어떤 구현이 실행될 것인지에 대한 Visibility를 가진다.
- inlining과 같은 최적화가 가능하다!
Dynamic Dispatch
- 런타임에 실행할 구현을 찾아서 결정한다.
- 사실 Static Dispatch랑 비교해서 비용 자체는 크게 차이나지 않는다. (단순히 테이블에 대한 간접 참조만 발생하기 때문)
- Reference Counting과 힙 할당과 같은 스레드 동기화 오버헤드도 없다.
- 그러나!! Static Dispatch와 다르게 컴파일러의 Visibility를 차단하기 때문에 inlining과 같은 최적화가 불가능하다..!!! (중요)
inlining 이란?
- 컴파일러가 위와 같은 코드에서 drawPoint라는 메서드를 다음과 같이 최적화 하는 것을 의미한다.
- 함수의 구현부를 간접 참조하는 것이 아니라 코드에 바로 대체시켜버린다. → 호출 스택 오버헤드가 없다.
동적 디스패치를 사용하는 이유
- 정적 디스패치에 비해 동적 디스패치를 사용하는 이유는 바로 다형성 때문이다.
- 상위 클래스인 Drawble을 상속받아 Point, Line과 같은 클래스를 생성했을 때, draw 메서드에 다형성이 발생한다.
- 각각의 인스턴스들을 담기 위해 drawables라는 어레이를 생성하고 [Drawble]로 타입을 지정한다.
- 이 인스턴스들은 참조 타입이기 때문에 같은 크기이다 → 어레이에 담을 수 있다. (Heap에 있는 인스턴스의 주소를 담게 됨)
- drawables 어레이 자체도 Heap에 저장되어 refCount를 가진다.
- drawables의 각 요소 역시 Heap에 있는 인스턴스를 참조한다.
- d.draw가 Point의 draw 일수도 있고 Line의 draw 일수도 있기 때문에 컴파일러는 컴파일 타임에 어떤 구현이 필요한지 알지 못한다. ⇒ 동적 디스패치가 필요하다.
- Swift는 동적 디스패치를 위해 각 클래스의 타입 정보에 대한 포인터를 클래스에 추가하고 이것(타입 정보)은 정적 메모리에 저장된다. (메모리의 데이터 영역인듯)
- 실제로 함수가 호출되면 올바른 구현을 찾기 위해 해당 타입 정보에 접근하여 Virtual method table에서 구현을 찾는다.
- Class에서 final 키워드를 붙이면 상속이 더이상 발생하지 않기 때문에 다형성으로 인한 동적 디스패치가 아닌 정적 디스패치가 사용된다. → 상속하지 않을 클래스라면 final을 붙이는 습관을 들이자!
좋은 코드를 위한 우리의 자세
- 앞으로 Swift 코드를 읽거나 작성할 때 다음과 같은 고민을 해야한다.
- 이 인스턴스는 스택과 힙 어디에 할당되는가
- 이 인스턴스를 전달할 때 오버헤드가 큰 참조가 얼마나 발생하는가
- 이 인스턴스에서 메서드를 호출할 때 정적, 동적 디스패치 중에 어떤 것이 사용될 것인가
- 필요 없는 dynamism은 제거해야 한다.
- 구조체를 사용하자!
- 그렇다면 구조체에서 다형성은 어떻게 구현하는가? ⇒ 답은 프로토콜이다.
Protocol Types
- 상속이나 reference semantics 없이 다형성을 구현할 수 있는 방법이다.
- 프로토콜을 사용하면 값타입인 struct를 사용해 다형성을 만들 수 있다.
Protocol Witness Table (PWT)
- 프로토콜을 이용한 다형성에서는 클래스에서의 V-table을 이용한 dynamic dispatch와 유사한 PWT를 이용한 동적 디스패치를 사용한다.
- 위의 예시 코드에서 Point 구조체는 프로퍼티를 2개 (2 words) Line 구조체는 프로퍼티 4개 (4 words)를 가진다. → 즉, 두 구조체의 크기가 다른다. 하지만 둘 다 drawables를 하나의 어레이에 담을 수 있다. ⇒ 어레이에는 크기가 같은 것들만 담을 수 있는데 어떻게 이것이 가능한 것일까? ⇒
Existential Container
가 이 질문의 답이다!
Existential Container
- Boxing values of protocol types
- Existential Container는 3 words의 공간을 가지는 구조체는 자체적으로 저장 (스택에)할 수 있도록 한다.
- Line 구조체처럼 3 words가 넘어가면 해당 구조체를 Heap 영역에 저장하고 Existential Container는 해당 Heap 영역을 가리키게 된다.
- 즉, 프로토콜을 채택한 구현체의 크기에 따라 Existential Container가 다르게 구성된다. ⇒ 이러한 차이를 관리하기 위한 것이 바로 VWT이다.
Value Witness Table (VWT)
- VWT는 value의 수명을 관리, 프로토콜 타입마다 이러한 테이블을 하나씩 가진다.
- VWT를 이용하여 변수의 allocate, copy, destruct, deallocate가 발생한다. (Existential Container와의 상호작용)
Existential Container (계속)
- Existential Container는 앞서 배운 3 words의 공간 말고도 VWT를 가리키는 주소를 가진다.
- 또한, PWT를 가리키는 주소를 가진다.
PWT 구성 = 프로토콜 타입의 변수를 저장할 3 words의 공간 + VWT 주소 + PWT 주소
Existential Container의 동작
- 앞선 예시의 연장선으로 위와 같은 코드가 실행될 때 Swift가 Existential Container를 이용해 어떻게 메모리를 관리하는지 살펴보자!
- Point의 인스턴스 val을 생선하면 위와 같이 Exixtential Container가 생성된다. (ExistContDrawble 구조체로 표현)
- 컴파일러는 Exixtential Container를 파라미터로 받는 drawACopy라는 함수를 새롭게 생성하여 프로토콜 타입의 다형성을 처리한다.
- vwt를 이용하여 Exixtential Container에 프로퍼티를 allocate 한다. (3 words 이하인 경우)
- Line 구조체처럼 4 words 이상이면 vwt에 의해 Heap 영역에 값들이 저장되며 Exixtential Container는 이를 가리키게 된다.
- 그 다음에 draw() 함수를 실행하기 위해 pwt를 이용한다. pwt에서 해당 함수의 올바른 구현부를 찾아서 실행시킨다.
- 여기서 vwt의 projectBuffer는 3 words 이하인 경우에는 existential container의 시작 위치, 4words 이상은 Heap 영역의 주소를 의미한다.
- draw 함수가 실행되고 스택 프레임이 생기면 해당 draw 함수를 실행한 인스턴스의 프로퍼티나 메서드를 필요로 할 수 있기 때문이라고 생각된다. → 즉, 자신을 호출한 인스턴스의 주소가 필요하다. 이것을 projectBuffer가 추상화하였다.
- 함수가 모든 동작을 마치면 위의 순서대로 destruct와 deallocate가 발생하여 Heap 영역의 메모리 공간(4 words 이상 일 때)과 Existential Container (Stack 영역)가 메모리에서 제거된다.
정리
- 프로토콜 타입을 이용해 값 타입에서 다형성을 구현할 수 있다.
- 클래스와 다르게 Reference Counting 오버헤드가 없다는 장점이 있다.
Protocol Type Stored Properties
- 저장 속성이 프로토콜 타입일 때의 Swift의 작동
- 이처럼 프로토콜 타입인 Drawable를 타입으로 하는 저장 프로퍼티가 있다고 한다면 다음과 같이 작동한다.
- first에는 Line, second에는 Point 인스턴스를 저장하면 위와 같이 스택의 Pair 영역에 Existential Container가 프로토콜 타입 프로퍼티의 개수만큼 (여기서는 2개) 생기고 그 내부 구조는 앞서 공부한 Existential Container의 구조를 따른다.
- 그렇다면 위와 같이 구조체의 복사가 발생한다면 메모리 영역에서도 복사가 발생하여 위의 그림처럼 Heap 할당이 여러번 (여기서는 4번) 발생하는 불상사가 생길 수 있다.
- 이러한 성능 하락을 해결하려면? 참조 타입과 COW의 연계!
- 만약 Line이 구조체가 아닌 클래스 (참조 타입)으로 구현되었다면 위와 같이 refCount를 이용해 중복되는 힙 할당을 막을 수 있다.
- 하지만, 이렇게 되면 서로 같은 주소 (힙 영역)을 공유하기 때문에 의도하지 않은 공유 (한쪽을 수정하면 다른 쪽에도 영향이 간다)가 발생한다.
- 따라서, 평상시에는 같은 주소를 가리키다가 (참조 타입처럼) 실제로 값 변경이 발생하게 되면 그제야 메모리에서도 복사가 발생하는 Copy-on-Write로 성능 최적화가 이루어지도록 Swift가 작동한다!!!!
COW의 구현
- 기존의 Line 구조체에 있는 x1, y1, x2, y2 프로퍼티를 LineStorage라는 클래스로 분리한다.
- Line은 LineStorage를 참조한다.
!isUniquelyReferencedNonObjc
는 해당 인스턴스의 참조 카운트가 1보다 클 때를 의미한다. ⇒ 같은 메모리 공간을 2 변수가 가리키고 있다. ⇒ 이제는 두 포인터를 분리하여 메모리에서도 실제로 복사가 발생해야 한다는 것을 의미한다! ⇒ COW
성능 요약
Generic
- 앞서 프로토콜 타입에서 사용된 예시 코드를 제네릭으로 바꾼 코드이다.
- 제네릭은 매개변수 다형성이라고도 하는 정적 다형성을 지원한다.
- 하나의 Context의 하나의 타입만 존재한다. (컴파일 타임에 정확한 타입을 확정할 수 있다.)
제네릭 메서드의 구현
- 형태는 기본적인 프로토콜 타입을 사용한 메서드와 유사하지만 내부적으로는 프로토콜과 다르게 existential container를 사용하지 않는다. 대신 VWT와 PWT를 함수의 추가적인 파라미터로 전달한다. (각 call context 마다 하나의 타입만 존재하기 때문)
- VWT를 이용해 버퍼를 할당(3 words 까지)한다. 메서드 디스패치는 PWT를 이용한다. 하지만 existential container가 없기 때문에 스택에 valueBuffer를 할당한다. 물론 3 words가 넘어가면 힙에 들어간다.
- 제네릭을 이용한 정적 형태의 다형성은 제네릭의 특수화라는 컴파일러의 최적화를 가능하도록 한다.
Specialization of Generics
- 제네릭을 사용하면 위처럼 컴파일 시점에 해당 타입에 상응하는 새로운 함수를 생성하게 된다.
- Point와 Line 두 타입에 해당되는 메서드가 필요하다면 위처럼 2개를 각각 만든다.
- ⇒ 코드 길이가 길어진다는 문제가 발생? ⇒ 이러한 정적 타이핑 정보는 높은 수준의 컴파일러 최적화를 가능하게 하기 때문에 이 방식이 더 효율적이다. (오히려 코드 양이 줄어들 수 있다.)
Specialization은 언제 발생하는가?
- Point.swift와 UserPoint.swif는 각각 다른 파일이기 때문에 원래는 각 파일에 적힌 코드에 접근 할 수 없어야하지만 Swift에서는 모듈 단위로 함께 컴파일하여 서로에 대한 접근이 가능해진다. (모듈 최적화 진행)
- 프로토콜 타입을 설명하며 나왔던 이 예시에서 제네릭을 사용해 정적 다형성 + 최적화가 발생하면 다음과 같은 결과를 얻을 수 있다.
- 정적으로 타입을 활정할 수 있기 때문에 힙 할당이 필요 없어진다. (메서드 디스패치를 위한 PWT 등 다양한 간접 참조가 필요 없어진다.) ⇒ 스택에만 저장 가능 (4 words 이상도!)
제네릭 코드의 성능
- 특수화를 거치면 좌측의 코드가 우측의 코드로 컴파일되어 작동하여 성능을 높인다.
요약
- 동적 런타임 타입 요구사항이 가장 적은 추상화를 선택하자
- 컴파일 타임에 컴파일러가 최대한 많은 정보를 알 수 있게 하자 ⇒ 최적화 가능해짐
- 가능하면 값타입을 주로 사용하자
- 프로토콜로도 다형성을 구현할 수 있으며 제네릭과 함께 사용하면 성능을 더 끌어 올릴 수 있다.
- 값 타입을 사용할 때 큰 값을 복사하는 오버헤드는 COW를 이용해 줄일 수 있다.
'iOS > Swift' 카테고리의 다른 글
Swift의 디자인 프로토콜 인터페이스 (0) | 2023.04.27 |
---|---|
ARC in Swift: Basics and beyond (0) | 2023.04.13 |
Explore structured concurrency in Swift (0) | 2023.02.11 |
Meet AsyncSequence (0) | 2023.02.11 |
Use async/await with URLSession (0) | 2023.02.11 |
댓글