WWDC/Swift

[Swift] WWDC16 - Understanding Swift Performance (2)

유정주 2022. 9. 7. 20:24
반응형

이전글

[Swift] Understanding Swift Performance (1)

 

지난 시간

지난 시간의 핵심은 최적화를 진행하는 방법이었습니다.

내 인스턴스가 스택과 힙 중 어디에 할당되는지, 인스턴스를 전달할 때 오버헤드가 얼마나 발생하는지, 인스턴스 메서드를 호출하면 어떤 디스패치로 동작하는지 고려해야 합니다.

 

구조체를 사용하여 다형성 코드를 작성하는 방법은 프로토콜 지향 프로그래밍입니다.

 

Protocol Types

프로토콜 타입을 알아보면서 프로토콜 타입 및 제네릭(Generic) 코드를 어떻게 구현해야 하는지 알아봅시다.

또, 프로토콜 타입의 변수가 저장되고 복사되는 방식과 메서드 디스패치가 작동하는 방식을 살펴봅시다.

 

이전 포스팅에서 다뤘던 Point와 Line을 프로토콜로 구현했습니다.

Drawable 추상 클래스 대신 draw 메서드를 선언한 Drawable 프로토콜이 있습니다.

그리고 프로토콜을 준수하는 Value 타입인 Point 구조체와 Line 구조체가 있습니다.

 

프로토콜을 준수하는 클래스도 만들 수 있었지만,

클래스는 reference sematic에 의한 의도하지 않은 공유를 일으킬 수 있어 생성하지 않기로 했습니다.

 

클래스를 만들지 않아도 여전히 다형성을 지원하며

이전과 동일하게 Point와 Line 모두 drawables 배열에 넣을 수 있습니다.

 

그러나 이전과 한 가지가 다릅니다.

Value 타입인 Point 구조체와 Line 구조체가 V-Table 디스패치를 수행하는데 필요한 공통 상속 관계를 공유하지 않습니다.

Swift는 V-Table 없는데도 어떻게 올바른 메서드를 결정할까요?

 

바로 Protocol Witness Table이라고 부르는 테이블 기반 메커니즘을 사용합니다.

앱에서 프로토콜을 구현하는 타입 하나당 PWT를 하나 가집니다.

 

그리고 해당 테이블의 항목은 타입 구현에 연결됩니다.

이제 해당 메서드를 찾는 방법에 대해 알게 되었습니다.

 

그러나 아직 배열의 요소에서 테이블로 이동하는 방법에 대한 것은 모릅니다.

 

그리고 값들을 일괄적으로 저장하는 방법에 대한 질문이 추가적으로 생깁니다.

Line은 4 word가 필요한데 Point는 2 word가 필요합니다.

배열은 고정된 공간에 균일하게 요소를 저장하지만 Line과 Point는 필요 공간이 다릅니다.

이럴 때 어떻게 해결할까요?

(이전에는 참조 타입이었지만 지금은 값 타입이기 때문에 생기는 의문임)

 

Swift는 이를 Existential Container를 이용해 해결합니다.

Existential Container는 특별한 스토리지 레이아웃입니다.

 

해당 Existential Container의 처음 3 word는 value buffer로 예약됩니다.

 

2 word만 필요한 Point와 같은 작은 타입은 이 valueBuffer에 들어갈 수 있습니다.

하지만 Line은 4 word가 필요하여 예약된 3word에 들어가지 않습니다.

 

이 경우에 Swift는 힙에 메모리를 할당하고 거기에 값을 저장한 뒤 해당 메모리에 대한 포인터를 Existential Container에 저장합니다.

이를 통해 Point과 Line 사이에 차이가 있음을 확인할 수 있습니다.

따라서 Existential Container는 이 차이를 관리해야 합니다.

이 관리는 Value Witness Table(VWT)이라는 테이블 기반 메커니즘에 의해 이루어집니다.

 

VWT는 value 수명을 관리하고, 타입별로 VWT를 하나씩 가집니다.

즉 Point도 Point 만의 VWT가 있고 Line도 Line 만의 VWT를 가지고 있습니다.

 

이 테이블의 작동 방식을 보기 위해 지역 변수의 lifetime을 살펴봅시다.

 

프로토콜 타입의 로컬 변수 lifetime이 시작될 때 Swift는 해당 테이블 내부에 assign 함수를 호출합니다.

 

allocate 함수는 힙에 메모리를 할당하고 해당 메모리에 대한 포인터를 Existential Container의 valueBuffer 내부에 저장합니다.

(Point는 힙에 저장하지 않기 때문에 이런 과정을 하지 않습니다.)

 

그다음 Swift는 로컬 변수를 초기화하는  assignment source에서 Existential Container로 값을 복사해야 합니다.

Point는 valueBuffer에 값이 저장되므로 valueBuffer에서 Existential Container로 값을 복사합니다.

Line은 힙에 값이 저장되었으니 힙에서 Exsitential Container로 값을 복사합니다.

 

이제 로컬 변수의 lifetime이 끝났다고 가정합시다.

Swift는 Value Witneess Table에서 destruct entry를 호출합니다.

desetruct는 값에 대한 래퍼런스 카운트를 감소시킵니다.

 

마지막으로 Swift는 VWT에서 deallocate 함수를 호출합니다.

Line에 대한 Value Witness Table이 있으므로 힙에 할당된 메모리를 할당 해제합니다.

 

이렇게 다른 종류의 값을 Value Witness Table을 이용해 일률적으로 다루는 방법을 보았습니다.

이제 이 Value Witness Table에 도달하는 방법을 알아봅시다.

 

Exsitential Container의 valueBuffer 다음 공간에는 VWT를 참조하는 포인터가 들어있습니다.

 

그 다음에는 Protocol Witness Table을 참조하는 포인터가 들어있습니다.

이 포인터들을 이용해 VWT와 PWT에 접근할 수 있습니다.

 

Existential Container 동작 예시 

drawACopy 함수는 local 이라는 이름의 프로토콜 타입을 파라미터를 전달 받아 draw 메서드를 실행시킵니다.

Drawable 프로토콜 타입의 로컬 변수를 생성하고 Point 인스턴스를 할당합니다.

이 변수를 drawACopy로 전달합니다.

 

Swift 컴파일러가 생성하는 코드를 설명하기 위해 의사코드를 추가적으로 작성하였습니다.

Existential Container는 valueBuffer에 대한 3 word 저장소와 Value Witness Table 참조와 Protocol Witness Table 참조를 가진 구조체입니다.

 

drawACopy가 호출이 되면 Generated code에서는 Swift가 해당 함수의 인자로 Existential Container를 전달하는 것을 볼 수 있습니다.

함수가 시작되면 해당 매개변수에 대한 지역 변수를 만들고 파라미터를 할당합니다.

 

이후 Generated code에서는 스택에 Exist Container를 할당합니다.

그다음 Existential Container에서 Value Witness Table과 Protocol Witness Table을 가져오고

 

local existential container의 프로퍼티를 초기화합니다.

 

valueBuffer에 들어가지 못하는 Line은

이렇게 힙에 할당이 됩니다.

 

다음으로 draw 메서드가 실행되고 Swift는 Existential container의 필드에서 PWT를 찾은 뒤 

 

해당 테이블의 고정 오프셋에서 draw 메서드를 찾은 다음 구현 부분으로 이동합니다.

(위 그림을 통해 Table들은 힙에 할당이 된다는 것을 알 수 있음 / 사실 위에서도 유추할 수는 있었지만 여기에서 명확하게  나옴)

 

여기에는 또다른 VWT가 존재합니다.

Generated code 마지막 부분에 projectBuffer 입니다.

pwt.draw 메서드는 값의 주소를 인자로 받습니다.

 

이 주소값은 valueBuffer에 들어가는 값이면 existential container의 시작 위치이고 

 

들어가지 못하는 값이면 힙에 있는 주소가 시작 위치입니다.

vwt.projectBuffer는 이러한 동작을 추상화하였습니다.

 

draw 메서드가 완료되면 함수의 끝에 도달합니다.

매개 변수에 의해 생성된 로컬 변수가 블록을 벗어나게 됩니다.

 

Swift는 값을 해제하기 위해 value witness 함수를 호출합니다. (Generated code의 마지막 부분)

할당되었던 힙의 메모리가 해제되었습니다.

 

힙 메모리 공간이 해제가 되면 스택 메모리 공간도 해제되면서 스택에 생성되었던 local existential container가 제거됩니다.

 

이 작업에서 주목해야 하는 점은 Line 구조체와 Point 구조체가 프로토콜과 결합하여 Value 타입을 유지하면서 동적으로 동작하고 다형성을 얻을 수 있다는 것입니다.

클래스는 V-Table을 이용해 참조 카운팅 오버헤드가 있기 때문에 보다 나은 방법입니다.

 

Protocol Type Stored Properties

프로토콜 타입 저장 프로퍼티에 대해 알아봅시다.

이 예시에서 Pair 구조체는 Drawable 프로토콜 타입의 first와 second 저장 프로퍼티를 가지고 있습니다.

 

Pair는 Drawable을 준수하는 모든 타입을 받을 수 있습니다.

그래서 Line과 Point를 하나의 Pair로 만들어 주었습니다.

이때 existential containers는 2개의 existential containers를 만들고

이 2개의 existential containers를 Pair 구조체로 감싸 pair의 inline에 저장합니다.

 

Line과 Point가 Pair라면 이렇게 할당이 될 것이고,

 

Line과 Line이 Pair라면 두 개 다 힙에 할당되는 모습이 될 것입니다.

 

위 예시에서는 힙 할당이 두 번 발생했는데요.

이때 힙 할당 비용을 더 자세히 살펴보겠습니다.

Line을 하나 만들고 동일한 Line 인스턴스 2개로 Pair를 생성합니다.

 

그리고 copy 상수에 pair를 할당합니다.

스택에는 두 개의 existential container가 생기고 네 개의 힙 할당이 생깁니다.

Line은 구조체임에도 valueBuffer에 들어갈 수 없어 힙에 할당이 되었습니다.

 

힙 할당은 비싼 작업이므로 이를 줄일 수있는 방법을 찾아야 합니다.

 

existential container는 3 word를 valueBuffer로 사용하고 Reference는 1 word이기 때문에 valueBuffer에 들어갈 수 있습니다.

만약 Line이 구조체가 아니라 클래스였다면 Reference Sematic에 따라 참조에 의해 저장되었을 것이고,

이 참조는 valueBuffer에 들어갈 수 있을 것입니다.

 

Pair에 동일한 인스턴스를 넣었을 때도, 참조만 복사하고 실제 인스턴스 복사는 일어나지 않아서

지불하는 유일한 비용은 참조 카운트 증가뿐이었을 것입니다.

 

하지만 Reference sematic은 의도하지 않은 상태 공유를 일으킵니다.

만약 Pair의 second.x1을 수정하면 first.x1도 변경됩니다.

 

이를 해결하려면 value semantic을 사용해야 하는데, 둘 다 만족하려면 어떤 기술을 사용해야 할까요?

그림으로 표현하면 이런 형태가 되길 원하고 있습니다.

이 문제는 Copy on Write로 해결할 수 있습니다.

 

Copy on Write

먼저 LineStorage라는 클래스를 생성합니다.

그리고 Line 구조체는 LineStorage 타입인 저장 프로퍼티를 가집니다.

기존 Line에 있던 4개의 저장 프로퍼티를 LineStorage로 모두 옮긴 형태입니다.

이러면 4 word 대신 1 word만으로 동일하게 동작할 수 있습니다.

 

Line의 값을 읽으려고 할 때마다 storage 안에 있는 값들을 읽으면 됩니다.

value를 수정할 때는 먼저 RC를 확인합니다.

만약 그게 1보다 크다면, LineStorage의 복사본을 만들고 이를 변경합니다.

위 예시를 통해 Copy on Write를 사용하는 간접 저장소를 얻는 방법을 알아보았습니다.

 

이번에는 간접 저장을 사용할 때 어떤 일이 발생하는지 알아봅시다.

동일한 인스턴스로 Pair를 만들면 처음 예시와는 다르게 같은 곳을 가리키고 있습니다.

LineStorage는 클래스이기 때문입니다.

 

pair를 copy에 할당해도 LineStorage가 클래스이기 때문에 같은 storage를 가리키게 됩니다.

이것은 힙 할당보다 훨씬 저렴한 작업입니다.

 

(

이런 모습을 원한다고 했는데 그림은 이렇지 않습니다.

하지만 동작은 동일한데요. move의 isUniquelyReferencedNonObjc 덕분입니다.

참조가 유일하지 않으면 인스턴스의 복사본을 만들어서 수정을 하기 때문에 실질적인 동작은 저희가 원했던 동작과 완전히 동일합니다.

)

 

저희는 지금까지 프로토콜 타입의 변수가 복사 및 저장되는 방식과 메서드 디스패치가 작동하는 방식을 살펴보았습니다.

이것이 성능에 어떤 의미가 있는지 살펴봅시다.

 

existential container의 valueBuffer에 들어갈 수 있는 값을 포함하는 프로토콜 타입은 힙 할당이 없습니다.

구조체에 참조가 포함되어 있지 않으며 참조 카운팅도 존재하지 않습니다.

따라서 이것은 정말 빠른 코드입니다.

value witness table과 protocol witness table을 통한 간접 방식이므로 동적 디스패치의 모든 기능을 얻을 수 있고 다형성도 가지고 있습니다.

 

하지만 valueBuffer에 들어가지 않을 정도로 값이 크면 변수를 초기화하거나 할당할 때마다 힙 할당이 발생합니다.

이런 구조체에 참조 프로퍼티가 있으면 잠재적인 참조 카운팅도 발생합니다.

 

Copy on Write와 함께 간접 저장소를 이용하는 방법은 값비싼 힙 할당을 대신하여 더 저렴한 참조 카운팅을 이용하였습니다.

클래스를 사용하는 것과 더 유리하게 보입니다.

 

중간 Summary

프로토콜 타입은 동적 형태의 다형성을 제공합니다.

프로토콜과 함께 값 타입을 사용하여 값 타입의 장점과 다형성을 이용할 수 있었습니다.

value witness table과 existential container를 이용해 목표를 달성했습니다.

하지만 큰 값을 복사하면 힙 할당이 발생하는 문제가 생겼고,

이는 간접 저장소를 이용하는 Copy on Write를 사용해 문제를 해결했습니다.

 

 

3편으로 이어집니다.

3편 바로가기 : Understanding Swift Performance (3)


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

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

공감 댓글 부탁드립니다.

 

 

반응형