저번 글 포스트에 이어서 계속해서 WWDC16 Understanding Swift Performance를 정리해 보겠습니다.
이번에는 Generic 타입 변수가 어떻게 저장되고 복사되는 지를 이야기해보겠습니다. 또한, method dispatch 또한 어떻게 진행되는 지 알아보겠습니다.
아래와 같은 코드가 존재한다고 해봅시다.
위 코드는 제네릭을 활용하여 제네릭 타입을 Drawable 프로토콜로 제약을 주었습니다. 그렇다면 위 코드와 그냥 파라미터 타입을 Drawable로 설정한 코드는 어떠한 차이가 있을까요?
Generic 타입은 Static Polymorphism을 지원합니다. 또 다른 말로는 Parametric Polymorphism이라고도 합니다. 위와 같은 코드에서 foo 함수가 실행되면, Swift는 generic 타입인 T를 Point 타입으로 바인딩합니다. 그리고 foo 함수안에 있는 local 변수는 Point 타입을 가지게 됩니다. 즉 T 타입은 Point 타입으로 대체가 됩니다.
결과적으로 위와 같이 타입은 매개변수를 따라서 call 체인으로 대체가 됩니다. 아직 정확히 이해를 못하겠으니, 그 다음 예를 통해서 이해해보록 하겠습니다.
위와 같은 코드와 같이 drawACopy()함수를 호출할 경우를 생각해봅시다. Swift는 하나의 call 컨텍스트 당 하나의 타입만 존재하기에 따로 existential container를 활용하지 않습니다. 그 대신 call site에서 추가적인 argument로 VWT와 PWT를 같이 넣어줍니다.
그리고, 함수 실행동안에(파라미터를 위한 local 변수를 만들 때), Swift는 VWT를 활용하여 Heap에 필요할 것 같은 버퍼들을 할당하고,(allocate 함수 실행!) assignment 소스에서 destination으로 복사를 진행합니다. (copy 함수 실행!)
그리고, local 파라미터의 draw 메소드를 실행하면, 전달된 PWT를 활용하여 테이블에서 draw 메소드를 찾아 바로 구현부로 jump 합니다. 근데 알다시피 저희는 existential container를 활용하지 않습니다. 그러면 로컬 파라미터의 메모리를 어떻게 할당하는 것일까요?
Swift는 valueBuffer를 stack에 할당합니다. 그리고 앞서 말했드시 valueBuffer는 3글자입니다. 따라서 Point와 같은 작은 값들은 valueBuffer에 딱 맞게 들어가게되죠. 그래서 heap에는 메모리가 할당되지 않습니다.
그러나, Point와 다르게 큰 값인 Line은 heap에 저장이되고, 로컬 existential container 속 메모리에 해당 포인터를 저장해둡니다. 그리고 이 모든 것은 VWT를 사용하기 위해서 관리됩니다.
그렇다면, 이렇게 하는 것의 이유는 무엇일까요?
위와 같은 정적인 다형성(static form of polymorphism)은 제네릭 특정화(Speicialization of Generics)라고 불리는 컴파일러 최적화를 가능하게 합니다.
만약, 위와 같은 코드가 있다고 다시 가정해봅시다. 그리고 앞서 정적인 다형성은 One Type at the call-site라고 말을 했습니다. 따라서 Swift는 해당 타입에 맞는 새로운 버전의 함수를 만들어서 generic 파라미터를 대체합니다.
이렇게 Generic 파라미터가 특정 타입(Point)로 바꿔치기가 되고, 새로운 Point 버전의 함수가 생성되는 것을 알 수가 있습니다.
그리고 Line타입을 drawACopy 함수의 파라미터로 호출하면, Line 버전의 새로운 함수 또한 생성이됩니다.
그렇다면, 여기서 생기는 의문점이 있습니다.
"이렇게 Swift가 Generic을 구현하면, Code Size가 계속해서 커지는 것이 아닐까?"
그러나, Static Typing Information은 Aggressive한 컴파일러 최적화를 가능하게 하므로 Code size의 증가를 막을 수 있습니다.
즉 아래와 같이 code size가 줄어드는 것이죠.
요렇게 새로운 버전의 생성된 함수 코드는 아래와 같이 변경이됩니다.
위와 같이 변경이되고,
요렇게 변경이됩니다.
결과적으로 이렇게 변경이됩니다. drawACopy 함수의 Point 메소드는 더이상 참조가 되어지지 않으므로, 컴파일러는 해당 코드를 삭제합니다. Line의 경우에도 Point와 똑같은 방식으로 구현이 되죠.
그렇다면, 언제 이러한 이러한 Optimization(Specialization)이 일어나는 것일까요?
위와 같이 Point 구조체를 정의하고, 해당 타입의 인스턴스를 생성해서 drawACopy 함수의 파리미터로 넣어주었습니다. Swift가 Sepcialization을 하기 위해서는 함수의 call site에서 타입을 추론할 수 있어야합니다. 위와 같은 경우에서는 local 변수를 확인하고, 해당 local 변수의 초기화 시점으로 돌아가서, Point 타입으로 초기화가 되었다는 것을 Swift가 알 수 있습니다.
그리고 Swift는 Specialization이 되는 동안에 사용된 type과 Generic 함수 그 자체 함수를 정의해야합니다.(Generic 함수와 Point와 같은 타입의 정의부를 한 파일에서 가지고 있어야한다.) 해당 예에서는 하나의 파일에 정의가 되어있으니, Swift가 speciailization을 할 수 있겠네요.
위와 같이 포인트 정의부를 다른 파일로 옮겨보았습니다. 이렇게 될 경우, 컴파일러는 서로 다른 파일은 각각 컴파일을 하기에 UsePoint 파일을 컴파일을 할때는 Point의 정의를 사용할 수 없습니다.
그러나, 전체 모듈 최적화(Whole Module Optimization)를 활용할 경우, 컴파일러는 두 파일은 하나의 유닛으로 컴파일을 하고, 최적화를 활용할 수 있게 됩니다. 이러한 과정은 최적화 기회를 매우 향상시키므로, Xcode 8부터는 전체 모듈 최적화를 디폴트로 활용할 수 있게 해두었습니다.
그럼 이러한 WMO를 어떻게 활용해볼 수 있을까요?
위와 같은 코드는 그림과 같이 두 개의 힙 할당을 유발합니다.
그러나, 위와 같이 Generic을 활용한 코드의 경우, 컴파일러는 같은 타입으로 파라미터 타입을 설정하도록 강제합니다. 즉 두 개의 파라미터 다 Line 타입으로만 받을 수 있도록 강제합니다. 그리고 해당 타입은 런타입 시점에는 변경할 수 없습니다. 이 말은 위의 그림과 같이 Pair라는 타입에 Line 타입 인스턴스 두 개가 묶여서 메모리에 올라가게 됩니다. 힙 할당이 없는 것이죠.
쉽게 말하자면 원래 existential container를 활용할 때는 heap에 저장이되었지만, 위와 같이 generic을 활용할 경우, Line 타입만을 받도록 강제하면서 Line 타입 자체가 프로퍼티로 들어가게 되죠! 따라서 heap에 할당되지 않는 것입니다. 이럴 경우, 기존의 프로토콜만 사용했을 때의 pwt를 통해서 메소드를 찾아보는 과정도 필요 없겠네요!
우리는 이렇게 VWT와 PWT를 활용하는 Unspecialized 코드를 확인해보았고, 타입 각각의 특정 버전을 만드는 제네릭 함수의 Specialized 코드를 확인해보았습니다. 그러면 이제 Specialized 코드의 퍼포먼스에대해서 좀 더 자세하게 알아보겠습니다.
구조체를 가지고 있는 Generic 코드의 경우, 구조체 타입에 맞게 새로운 함수를 정의합니다.구조체 타입을 복사할 때 Heap 할당이 일어나지 않고, 구조체가 참조 프로퍼티를 가지고 있지 않을 경우 레퍼런스 카운팅도 없습니다. 또한 런타임을 줄여주고, 컴파일러 최적화를 해주는 Static Method Dispatch를 하게 됩니다.
Class Type의 제네릭 코드의 경우, Heap 할당, 레퍼런스 카운팅, V-table을 활용한 Dynamic Method Dispatch가 일어납니다.
그러면 작은 값을 가지고 있는 Unspecialized 코드를 확인해보겠습니다. Stack에 있는 valueBuffer에 값들이 딱 들어맞기에 로컬 변수에 대한 heap 할당은 없습니다. 또한 값이 레퍼런스를 가지고 있지 않으면 레퍼런스 카운팅도 없습니다. 그러나, 모든 call site에서 PWT를 활용하여(Existential Container의 활용) 함수를 호출합니다.
큰 값을 가지고 있는 제네릭 코드의 경우, indirect 스토리지를 활용한 heap 할당을 유발할 수 있습니다. 또한 값이 레퍼런스 값을 가지고 있다면, 하나의 제네릭 함수의 구현을 공유하는 다이나믹 디스패치를 활용합니다.
총 정리를 해보겠습니다.
우리는 타입을 만들 때, 먼저 적은 다이나믹 런타임을 요구하는 엔티티에 대한 적절한 추상화를 선택해야합니다. 이와 같은 과정은 정적인 타입 체크를 가능하게 하고, 컴파일러가 컴파일 타임에 모든 것을 결정하게 하고, 코드를 최적화할 수 있는 더 많은 정보를 제공하여 더 빠른 코드를 구현할 수 있습니다. 따라서 value semantics를 활용할 수 있는 struct와 enum을 활용하여, 최적화를 할 수 있는 코드를 작성하는 것이 중요합니다.
그러나, 만약 OOP에 맞는 엔티티를 생성해야한다면, class를 활용하는 것을 고려해볼 수 있습니다. 만약 프로그램이 정적인 다형성(static polymorphism)을 활용하려고 한다면, 값 타입과 제네릭을 활용하면 정말 빠른 코드를 작성할 수 있습니다. 만약 Drawable 프로토콜 타입의 배열을 만드는 것과 같은 동적인 다형성(Dynamic Polymorphism)을 활용하기 원한다면 값 타입과 프로콜을 활용할 수 있습니다. 이와 같은 방식은 value semantics를 활용하여 class를 활용하는 것보다 더 빠른 코드를 작성할 수 있습니다.
또한, indirect storage를 활용하여 복사하고 쓰기를 한다면, 프로토콜 타입이나 제네릭을 활용하여 큰 값을 복사하여 생기는 heap 할당 문제를 해결할 수 있습니다.
'Swift' 카테고리의 다른 글
[Swift] WWDC21 - ARC in Swift: Basics and Beyond (0) | 2023.08.15 |
---|---|
[Swift] [weak self]는 언제 사용할까? (0) | 2023.08.11 |
[Swift] WWDC16 Understanding Swift Performance(2) (0) | 2023.07.31 |
[Swift] WWDC16 Understanding Swift Performance(1) (0) | 2023.07.31 |
[Swift] 다형성을 활용하여 Enum 대체하기 (0) | 2023.07.26 |