WWDC/Swift

[Swift] WWDC16 - Understanding Swift Performance (3)

유정주 2022. 9. 8. 00:26
반응형

이전 글

Understanding Swift Performance (2)

 

Generic

drawACopy는 Generic을 이용해 매개변수 제약 조건을 주고 있습니다.

이는 프로토콜 타입과 무엇이 다를까요?

 

 

제네릭 코드는 매개변수 다형성이라고도 하는 보다 정적(static) 형태의 다형성을 지원합니다.

함수 foo는 Generic으로 만들어졌고 Drawable만 받을 수 있습니다.

Point는 Drawable 프로토콜을 준수했으니 foo의 파라미터로 사용할 수 있습니다.

 

이 foo 함수가 실행되면면 Swift는 제네릭 타입 T를 Point 타입에 바인딩 합니다.

함수 foo가 바인딩과 함께 실행될 때 bar가 호출되면 local 변수는 직전에 사용한 타입인 Point를 가지게 됩니다.

타입은 매개변수를 따라 호출 체인으로 대체됩니다.

 

이것이 정적인 형태의 다형성(or 매개변수적 다형성)을 의미합니다.

 

Implementation of Generic Methods

이제 Swift가 내부적으로 이것을 구현하는 방법을 살펴봅시다.

 

다시 drawACopy 함수 구현 코드로 돌아가 봅시다.

이번에는 drawACopy 메서드에 Point를 전달합니다.

프로토콜 타입을 사용했을 때와 마찬가지로 하나의 공유 구현이 있습니다.

 

이 메서드가 어떤 구현부를 가지는지 모르기 때문에 프로토콜과 마찬가지로 PWT, VWT를 이용해야 합니다.

다른 점은 호출 컨텍스트 당 하나의 타입만 있기 때문에 Swift는 여기에서 existential container를 사용하지 않습니다.

대신 이 호출에서 사용된 Point 타입의 VWT와 PWT를 함수에 대한 추가 인자로 전달합니다.

따라서 이 예시에서는 Poin에 대한 VWT가 전달될 것입니다.

 

그다음 해당 함수를 실행하는 동안, 매개변수에 대한 로컬 변수를 만들면 Swift는 VWT를 사용하여 필요한 모든 버퍼를 힙에 할당하고 원본에서 목적지(destination)로 복사를 실행합니다.

draw 메서드를 실행하면, 전달된 PWT를 이용해 테이블 내의 고정 오프셋의 draw 메서드를 찾아 구현부로 이동합니다.

여기에서 existential container가 없다고 말했었는데요.

그러면 Swift는 이 local argument에 대해 필요한 메모리를 어떻게 할당할까요?

(let local = Point()에 필요한 메모리를 의미함)

 

바로 스택에 valueBuffer 3 word를 할당합니다.

 

Point는 valueBuffer에 들어갈 크기이기 때문에 valueBuffer에 값이 들어갑니다.

 

그렇지만 Line은 valueBuffer에 들어가지 못할 크기라서 힙에 할당되고 참조 포인터가 valueBuffer에 저장됩니다.

이 모든 것은 Value Witness Table을 사용해 관리됩니다.

 

그럼 이제 제네릭이 정말 더 나은지, 그냥 여기에 프로토콜 타입을 사용하면 안 되는지 하는 의문이 생길 수 있습니다.

이 static 형태의 다형성은 제네릭의 특수화(Generic specialization)라고 하는 컴파일러 최적화를 가능하게 합니다.

 

Specialization of Generics

다시 drawACopy로 가봅시다.

이번에는 Point를 파라미터로 전달했습니다.

우리는 static 형태 다형성을 가지고 있는 call site에는 하나의 타입만 있습니다.

 

Swift는 해당 타입을 사용하여 함수에서 Generic 매개변수를 대체하고 해당 타입에 고유한 버전의 함수를 만듭니다.

이 코드는 정말 빠른 코드가 될 수 있습니다.

(WWDC 2015 - OptimizationTips에서도 나온다고 하네요)

 

만약 Point가 아니라 Line을 전달한다고 하면

Line 타입 버전의 함수가 생기는 것입니다.

 

하지만 이것은 코드 길이를 증가시킬 위험성이 있습니다.

타입마다 해당 함수의 버전을 만들기 때문입니다.

하지만 aggressive compiler optimization(적극적인 컴파일러 최적화)를 가능하게 하기 때문에 Swift는 코드 크기를 줄일 수 있습니다.

 

예를 들어 Point 메서드 함수의 drawACopy를 인라인 합시다.

(아래에서 세 번째, 두 번째 라인)

 

그리고 불필요한 컨텍스트를 줄여 코드를 최적화합니다.

결국에 함수 호출은 단 한 줄로 줄어들 수 있고 이것은 draw 구현으로 훨씬 더 줄일 수 있습니다.

 

Line도 동일한 방법으로 줄이면 이런 코드가 됩니다.

 

이제 Point 메서드의 drawACopy가 더이상 참조되지 않으므로 컴파일러는 이를 제거합니다.

따라서 이 컴파일러 최적화가 반드시 코드 크기를 증가시키는 것은 아닙니다.

 

When Does Specialization Happen?

specialization가 어떻게 동작하는지 알았으니 specialization가 언제 발생하는지 알아봅시다.

Point를 정의한 다음 해당 타입의 지역 변수를 생성했습니다.

point를 Point로 초기화한 뒤 drawACopy 함수에 전달합니다.

이 코드를 specialization 하기 위해 Swift는 이 호출 site에서 타입을 유추할 수 있어야 하는데,

로컬 변수의 초기화를 보고 타입을 확인할 수 있기 때문에 유추가 가능합니다.

 

Swift는 specialization 과정에서 사용된 타입과 Generic 기능 자체를 사용할 수 있는 함수를 정의해야 합니다.

여기서도 마찬가지이고 모두 하나의 파일에 정의되어야 합니다.

이것은 전체 모듈 최적화(Whole Module Optimization)가 최적화 기회를 크게 향상시킬 수 있기 때문입니다.

 

Whole Module Optimization

Point 정의를 다른 파일로 이동시켰다고 생각해 봅시다.

두 개의 파일을 개별적으로 컴파일하면 UsePoint.swift 파일을 컴파일할 때,

컴파일러에서 두 파일을 따로 컴파일하기 때문에 Point 정의를 더이상 사용할 수 없습니다.

 

그러나 Whole Module Optimization을 통해 컴파일러는 두 파일을 하나의 단위로 컴파일하고,

Point.swift 파일에 대한 Insight를 가지게 되며 최적화가 수행될 수 있습니다.

이렇게 하면 최적화 기회가 크게 향상되어 Xcode 8부터는 기본적으로 WMO가 활성화됩니다.

 

이전 포스팅에서 다룬 Pair를 봐봅시다.

Line의 경우 valueBuffer에 fit하지 않기 때문에 힙 할당이 필요했고

별도의 간접 저장소(Indirect storage)도 없었기 때문에 힙 할당이 두 번 발생했습니다.

여기서 제네릭을 사용해봅시다.

 

Pair의 first와 second 타입을 제네릭 타입으로 설정하면 컴파일러는 동일한 타입만 전달되도록 강제할 수 있습니다.

이렇게 하면 나중에 프로그램에서 Line Pair에 Point를 저장할 수 없게 됩니다. (Pair(Point(), Line())이 안 된다는 의미임)

이것은 우리가 원했던 기능이지만 성능은 더 좋을까요 나쁠까요?

 

Generic Stored Properties

일단 런타임에 타입을 변경할 수 없다는 것을 기억해야 합니다.

 

Pair가 있고 저장 프로퍼티가 제네릭 타입입니다.

이렇게 하면 Swift가 enclosing 타입의 저장소를 인라인으로 할당할 수 있습니다.

 

따라서 두 개의 Line을 생성할 때 Line에 대한 메모리는 실제로 enclosing의 인라인으로 할당됩니다.

(Pair로 두 개의 Line 메모리 공간을 감싼 그림)

즉, 추가 힙 할당이 필요하지 않습니다.

 

추후에 Line이 아닌 다른 타입을 저장할 수도 없습니다. (굿)

 

Value Witness Table과 Protocol Witness Table을 사용하여 Specialized 되지 않은 코드가 작동하지 방식과

컴파일러가 제네릭 함수의 타입별 버전을 생성하는 코드를 specialize 할 수 있는 방법을 살펴보았습니다.

 

구조체를 포함하는 제네릭 코드를 먼저 살펴보고 성능에 대해 알아봅시다.

구조체 타입의 값을 복사할 때 힙 할당이 필요하지 않으며 참조가 포함되지 않은 경우 RC도 필요하지 않습니다.

그리고 새로운 버전의 메서드를 만들어내기 때문에 static method dispatch도 가능해지죠.

 

클래스를 살펴봅시다.

클래스이기 때문에 힙에 할당되고 RC, V-Table을 통한 Dynamic Dispatch가 가능합니다.

따라서 구조체보다 모든 면에서 성능이 좋지 않습니다.

 

제네릭을 사용하지 않은 작은 값도 보도록 하겠습니다.

제네릭을 사용하지 않으니 exsitential container를 사용할 것이고 작은 값이므로 valueBuffer에 할당됩니다.

그래서 힙 할당이 필요 없고 existential container에 PWT를 통해 메서드를 호출합니다.

 

하지만 valueBuffer에 들어가지 않는 큰 값이라면 힙에 할당해야 하고 RC가 존재하게 됩니다.

메서드 조회는 small value와 동일하게 PWT를 통해 관리됩니다.

 

Summary

우리는 동적 런타임 요구사항이 가장 적은 entity에 대해 적합한 추상화를 선택해야 합니다.

이렇게 하면 static 타입 검사가 가능해지고 컴파일러는 컴파일 타임에 프로그램이 올바른지 확인하고 코드를 최적화하여 더 빠른 코드를 얻을 수 있도록 합니다.

따라서 struct와 enum과 같은 value 타입을 사용하여 프로그램에서 entity를 표현할 수 있다면 value sematic을 얻을 수 있습니다.

이는 의도하지 않은 상태 공유를 방지하며 매우 최적화된 코드를 얻을 수 있습니다.

 

좀 더 static한 형태의 다형성을 사용하여 프로그램의 일부를 표현할 수 있다면,

Generic 코드를 value 타입과 결합할 수 있고 이는 정말 빠른 코드를 얻을 수 있으면서

해당 코드의 구현을 공유할 수 있습니다.

 

마지막으로 Drawable 프로토콜 예제와 같이 동적인 다형성이 필요하다면,

프로토콜을 value 타입과 결합하여 class보다 더 빠르지만 여전히 value sematic에 머물도록 사용할 수 있습니다.

 

감사합니다.


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

 

반응형