티스토리 뷰

반응형

안녕하세요 :) Zedd입니다.

모두들 연휴는 잘 보내시고 계시나요!?

저는 토, 일동안 엄청나게 게으른 생활을 하고.....이렇게 살면 안되겠다 싶어서 카페로 피신하였ㄱ읍니다.

Swift ) Understanding Swift Performance (Swift성능 이해하기)을 계속 공부하도록 할게요 XD



Understanding Swift Performance (Swift성능 이해하기) 



자, 이제 Swift의 음...Swift의......꽃...? 아무튼 Protocol과 Generic을 보도록 할게요.

일단, 프로토콜 타입의 변수가 저장되고 복사되는 방법과 method dispatch가 작동하는 방법을 보도록 합시다.



저번 글에 나온 예제와 똑같지만, 하나 달라진점이 있다면, Drawable이 클래스가 아니라, 프로토콜이라는 점이죠.

 그리고 Point와 Line도 클래스에서 구조체로 바뀌었네요.

그리고 Drawable이라는 프로토콜을 만들어줬으니, Point와 Line이 이 프로토콜을 채택하고, 준수하도록 draw라는 메소드도 구현해줍시다.

그리고 drawables이라는 Drawable타입의 배열을 만들어줍시다.

이 배열안에는 Drawable프로토콜을 준수하는 타입이면 다 들어갈 수 있죠.


자...Point와 Line이 클래스였을때를 잠깐 한번 보면, 컴파일러는 d.draw()에서 컴파일타임에 어떤 draw(Point의 draw, Line의 draw?)를 호출해야 할지 직관적으로 알 수 없기때문에, 해당 타입의 정보를 가지고 vtable을 조회한 뒤, 해당 draw메소드를 찾아서 실행시킨다고 그랬죠! (dynamic distpach)



근데, 지금은 vtable조회같은 걸 할까요?



왜냐하면 Point와 Line은 구조체이기 때문에, 

class였을 때처럼 vtable dispatch를 하기 위한 공통 상속 관계(common inheritance relationship)가 필요하지 않습니다. 



지금은 바로 이러한 상황이죠.


그럼 vtable도 조회안해? 근데 컴파일타임에 어떤 draw()가 호출될지도 몰라;; 

==> 그럼!!!! 그럼 이러한 상황에서 Swift컴파일러는 어떻게 올바른 method dispatch를 하느냐는 거죠.


답은, table기반 메커니즘인 Protocol Witness Table입니다.

(만약 protocol없이 그냥 struct로 짰다면 static dispatch로 했겠죠? 

하지만 Swift에서는 struct가 상속이 불가능하다는 점을 프로토콜을 사용해서 극복 할 수 있었어요. 

지금 상황은 struct + protocol 일 때 발생한다는 것을 계속 생각하시면 됩니다.

그래서 Protocol Witness Table같은게 필요하겠죠.)


해당 테이블의 엔트리는 해당 타입의 구현에 연결(link)됩니다.



바로 이렇게말이죠.

그래서



요렇게 메소드를 찾을 수 있게 됩니다.


자! 그럼 우리는 지금 메소드를 찾는 방법에 대해 안거에요.

하지만 궁금한게 또 있죠. 

저 drawables라는 배열요소에서 table로 어떻게 이동할 수 있는건지말이죠.

(지금 drawables안의 요소가 ?로 되어있는 것 보이시죠?)

일단 이건 킵해놓고;;


자, 위 그림에서 볼 수 있듯이, Point와 Line에 저장프로퍼티들이 생겼습니다.

Point는 2개, Line은 4개가 있네요.



그럼 얘네 둘이 똑같은 사이즈를 가질 필요는 없죠.

하지만, 배열은 고정된 offset에 요소를 저장하려고 합니다. 

근데 굳이 똑같은 사이즈일 필요 없는데 말이죠....ㅠ



이럴때 어떻게 작동하는지 알아봅시다.

바로 이럴 때, Swift는 Existential Container라는 특수한 storage layout을 사용한다는 겁니다.


자 한번 Existential Container가 어떻게 생겼는지 봅시다.



바로 이렇게 생겼는데요, 이 컨테이너의 처음에는 이렇게 3개가 valueBuffer용으로 예약되어있습니다.


그래서 Point와 같이 x, y두개만 필요하면



지금 3개가 있었으니까, Point와 같이 작은 타입은 이 valueBuffer에 들어맞죠.

그럼 Line은...? 4개가 필요한데요 ㅠ



Swift는 Line같이 큰 타입은 Heap에 메모리를 할당하고 해당 메모리에대한 포인터를 Existential Container에 저장합니다.


그럼 우리가 지금 알 수 있는 사실은 Point와 Line과 일단 차이가 있다는 거죠. 다른방식으로 저장되니까요.

그럼 Existential Container는 이 차이(difference)를 관리 할 필요가 있어요.

그럼 이 관리를 어케하느냐;;


또 다시..........table 기반 메커니즘인 "Value Witness Table"입니다. 

위에서 말한건 "table기반 메커니즘인 Protocol Witness Table" 이고, 이번건 Value Witness Table입니닷

Value Witness Table은 value의 lifetime을 관리하며 타입마다 Value Witness Table이 있습니다. 

그러니까!!!!!!!!! 이 Value Witness Table은 Point꺼도 있고, Line꺼도 있다!!! 라는 것이죠.

각각 가지고 있다라는 말입니다. 


이제 로컬변수의 lifetime을 보고, 이 Value Witness Table의 작동방식을 알아봅시다.



프로토콜타입의 로컬 변수 lifetime이 시작될 때, Swift는 Value Witness Table내부의 allocate함수를 호출합니다. 

(지금은 Line Value Witness Table 볼거에요)



자, 지금은 Line Value Witness Table을 보고있죠? 

위에서 말했듯이 Line은 Existential Container에 들어맞지 않기때문에 Heap에 메모리 할당이 필요했었어요.

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

이 세션에서 나오진 않았지만, 위는 Line이라서 (= valueBuffer에 들어맞지 않아서) allocate안에서 Heap메모리 할당 어쩌고 저쩌고를 해줬지만, Point의 경우에는 이런 작업들을 하진않겠죠?



다음으로 Swift는 local 변수를 초기화하는 assignment소스에서 Existential Container로 값을 "복사"해야합니다. 

자 생각해봅시다. 

아까 Point는 valueBuffer에 들어맞기때문에 이 valueBuffer내에 값들을 저장한다고 그랬죠?

 그럼 Point에서는 이 copy부분에서 Existential Container로 값을 복사하겠네요.

그럼 Line은? Line은 valueBuffer에 들어맞지 않았었죠!! 그래서 Line같은 경우, 이 copy에서는 Heap에 값들이 복사되겠죠/



자 이제 다 쓰고, 우리의 local 변수가 lifetime이 끝났다고 생각해볼게요.

그럼 Swift는 Value Witness Table에서 destruct 엔트리를 호출합니다.

destruct는 값에 대한 레퍼런스 카운트를 감소시키죠.



그리고 진짜로 끝나면, Swift는 deallocate함수를 호출하죠.

Line은 여기서 Heap메모리 할당을 해제합니다.


자. 이렇게 Swift가 이렇게 다른 종류(?)의 value를 다루는 매커니즘을 알아보았는데요,

Existential Container가 어떻게 작동하는지 다시한번 보도록하죠.





그러니까 정리하면, Point와 Line을 관리하는 방법은 일단, 각각 Existential Container이 만들어지고, (각각 만들어지는게 맞겠죠...????)

아니 헷갈리게 왜 이렇게 해놓은지 모르겠지만...(vwt은 Line꺼를 가리키고, pwt는 Point꺼를 가리켜서 ㅠ)

이 Existential Container안에 vwt(value witness table)와 pwt(protocol witness table)에 대한 레퍼런스를 저장하는데, 

vwt에서 저장프로퍼티들을 관리하고, pwt에서 프로토콜 메소드를 관리하나보네요. 

아!!!! 그럼 Existential Container가 각각, 즉 Point, Line별로 만들어지는게 맞네요.

왜냐하면 이 Existential Container안에서 pwt를 보고 draw를 찾아서 실행할테니까, 각각 Existential Container를 가지고 있어야 그게 말이 되네요...

일단 제 생각이고, 틀리면..알려주시길 바랍니다ㅎ


자 친절하게 Existential Container가 어떻게 작동하는지에 대한 예제를 올려주심 ㅎ

같이해볼까요? 



자, 이런 코드가 있습니다. 

val은 Drawable프로토콜 타입이며, Point가 Drawable을 준수하고 있으므로 Point()로 초기화해도 문제없죠.

(근데...x랑 y는 사라진건가..? 왜 Point()로만...ㅎ)

아무튼 계속 봅시다.

drawACopy로 우리가 방금 만든 변수를 전달하고, drawACopy안에서는 draw메소드를 호출하네요.

그럼 이러한 코드가 있을 때, Swift는 어떤 일을 하냐!?!? 한번 봅시다.

지금 val은 Drawable타입이기때문에, Swift는 직관적으로 어떤 draw를 호출해야할지 모르는 상황같네요. 



아래에 있는 코드들이 생성되게 되는데요,

3개를 저장할 수 있는 valueBuffer가 있고 (위 코드 보셈) 

vwt과 pwt에 대한 레퍼런스가 있는 existential container 구조체가 하나 만들어지네요.


자, 우리가 drawACopy(val)을 호출하면


Swift는 drawACopy의 argument로 existential container를 전달합니다.



함수가 실행되면, 해당 매개변수에 대한 로컬 변수가 만들어지고 argument가 할당됩니다.



자, 이제 drawACopy를 실행하면 어떤 일이 일어날지...봅시다..

Swift는 drawACopy의 argument로  existential container 구조체 타입을 넘기는데요, Swift는 stack에 existential container를 만듭니다. 

(세션에서는, Swift will allocate an existential container on the heap. < Heap에 existential container를 할당한다고 되어있는데.....잘못 말한거겠죠..? stack을 Heap으로..잘못말한거죠..? 아놀드..? 저는 그림을 믿겠습니다........)



그런 다음 existential container에서 vwt와 pwt를 읽고, local의 필드를 초기화합니다.



다음으로, 필요한경우 valueBuffer를 할당하고, 값을 복사하는 value witness function(copy인듯)을 호출합니다.

아니!!!!!!!!!!!!!!!!! 왜 또 LineVWT가 나오는데요......지금 하고있는거 Point인데......

ㅎㅎ근데 위 그림은 Line이면 이렇게 된다~~라는 것을 보여주는 거였음..



아 Point의 vwt도 어쨌거나 Heap에 만들어지네..?

vwt와 pwt모두 Heap에 만들어진다는 것을 알 수 있음.

그럼 어쨌거나 pwt는 Heap에 만들어진다는 것..? Point여도?


암튼 계속 할게요. Point는 Heap할당이 별도로 필요하지 않으므로



이렇게 되겠네요. 마지막 코드를 보면, argument의 값을 local의 valueBuffer에 복사합니다.



결국 Point는 위와같은 그림이 되겠고


Line은 Heap할당이 필요했으니 이렇게 되겠네요. 


그럼 draw메소드가 실행될 때를 봅시다. 


(Line이라는 점 주목)

그럼 draw()를 호출하는순간, Swift는 existential container의 필드에서 pwt를 조회하고, 해당 table의 fixed offset에 있는 draw메소드를 조회하여 구현으로 이동합니다. < 이게 핵심이네여


그런데...... projectBuffer라는 것이 있습니다. projectBuffer역시 value witness입니다. 



아니 웬 갑자기 projectBuffer;;;;;;;이건 머에여

draw메소드는 인풋의 value로 주소가 들어올것으로 예상하고 있죠.

그리고 value는 inline buffer에 들어맞는 작은값인지(Point처럼)여부에 따라 이 주소는 existential container의 시작주소이거나 Line처럼 valueBuffer에 맞지않는 value면 Heap에 할당된 메모리의 시작주소일 겁니다.

따라서. 이 value witness 함수는 타입에 따라(Point처럼인지 Line인지) 이 차이를 추상화(abstracts)합니다.



그러니까, draw를 호출하는 순간, Point의 경우에는 existential container의 시작주소가 들어갈것이고, 



Line같은 경우에는 Heap의 시작주소가 들어갈 것이라는 거..?


draw메소드가 실행되고, 완료되면 "local"이라는 변수가 scope를 벗어나게 되며,

Swift는 value witness 함수인 destruct를 호출합니다.


이게 호출되면..!!


위에서 머라그랬음 destruct가 호출되면 레퍼런스 카운트를 모두 감소시키고 buffer가 할당된경우 buffer를 할당해제(deallocate)합니다.



요러케


자...정말 많은 것이 일어났죠......

이런방식을 통해 struct가 프로토콜과 함께 결합하여 dynamic behavior, dynamic polymorphism을 얻을 수 있게 되는 것입니다.

이러한 dynamism이 필요하면 위에서 Point와 Line이 class일때와 비교하여 좋은 비용이죠. 

왜냐하면 class일때는 vtable조회와 추가적인 레퍼런스 카운팅 오버헤드가 있었거든요.

(흠 근데 이것도 어짺ㅆ든 pwt조회는 해야하는댑...이건 비용이 괜찮나..? class에 비해 상대적으로 괜찮나보네요.)


예제를 하나 더 볼건데 

그 전에,

지금까지 이게 무슨소리야...하실 분들을 위해 정리한번 하겠습니다.

왜냐면 지금 한번 읽어봤는데 만약...이런 개념이 처음이라면 이해가 1도 안갈것 같기 때문이죠.


1. 먼저 Point와 Line이 class였을 때 (1편 마지막 부분) dynamic distpach가 일어난건 기억하시죠?

Swift가 컴파일타임에 어떤 draw()가 호출되어야 할지 모르기때문에

 런타임에 타입의 정보를 가지고 vtable을 조회하여 실행 할 draw를 찾죠.


2. 하지만 지금은 Point와 Line이 class가 아니라 struct일때를 생각해보자..이거에요.

하지만 저번글에도 이야기 했듯이 struct는 static dispatch를 사용합니다. 

즉 Swift는 컴파일타임에 어떤 구현이 실행될지 알고있다는 것이죠.


3. Swift에서 struct는 상속이 불가능하다는 것은 다들 알고계실겁니다.

그리고 이러한 한계를 protocol로 극복하려고하죠.


4. 만약 우리의 여러 struct가 한 protocol을 채택하고 준수하여



이러한 상황이 되었을 때, 여기서 Swift컴파일러는 d.draw()에서,

어떤 struct의 draw가 불려져야될지 어떻게 아는걸까??에 대한 것을 지금까지 공부한거에요.

왜냐면, 이때도 class때와 마찬가지로 Swift컴파일러는 어떤 draw를 호출해야할지 직관적으로 알 수 없기때문입니다.

== static dispatch가 안된다.


 어떻게 아는지 한번 정리해봅시다.

5. 먼저, Swift는 existential container라는 것을 Stack에 만듭니다.


6. existential container는 inline valueBuffer (공간 3개), 

value witness table(vwt)의 레퍼런스, 

protocol witness table(pwt)의 레퍼런스를 갖고 있습니다.


7. vwt와 pwt는 위에서 Point와 Line에 상관없이 Heap에 할당되는 것 같아요. 

existential container는 Heap에 할당된 그 2개의 table의 레퍼런스를 가지고 있구요.


8.  vwt는 위에서 말했다시피 4가지의 엔트리를 가지고 있는데요.

allocate에서는 Line같이 inline valueBuffer가 안되는 애들이라면 Heap에 메모리를 할당합니다.

그리고 existential container가 해당 Heap메모리 시작주소를 가리키고 있게 되겠죠.

만약 Point같은 애들이라면 여기서 별다른 작업을 안할거라고....생각하는데 이건 제 추측입니다.


9. 그리고 copy단계에서 실제로 값을 할당합니다. 

Line같은 경우에는 Heap에 값들을 할당할것이고

 Point같은 경우에는 stack inline에 값을 할당합니다.


10. 그리고 우리가 draw라는 메소드를 호출하는데요,

우리가 궁극적으로 알고싶은건 이 draw가 어떤 struct의 draw인것을 Swift가 아는 것이냐??였죠?

draw는 Protocol메소드였기 때문에 pwt를 조회합니다.


이 pwt를 조회해서 draw를 찾으면 게임 끝이라는 거죠.

왜냐? 내가 이 pwt의 레퍼런스. 즉 내가 이 existential container에 들어온 순간,

이 existential container는 해당 타입의 existential container일거거든요.

그럼 pwt에는 해당 타입의 draw가 있을 것이 자명하므로 나는 그 draw주소로 가서 구현을 찾아서 실행시키면 되는거에요.

그냥 한마디로 위에서 언급했듯이

" Swift는 existential container의 필드에서 pwt를 조회하고, 해당 table의 fixed offset에 있는 draw메소드를 조회하여 구현으로 이동합니다."


11. 이제 draw가 끝나면 vwt에 있는 destruct를 호출하여 (Heap에 메모리 할당이 되었다면) 레퍼런스 카운팅을 감소시키고 deallocate에서 Heap할당을 해제하고 stack메모리도 해제시킵니다.

(== stack포인터 값을 증가시킴으로써)


그러니까 이렇게 해서 Swift는 struct가 프로토콜을 준수했을 때, 어떤 draw를 불러야 할 지 이런 방식을 통해서!! 알게 된다는 것을 배운거에요.






예제 하나를 더 볼게요.



Pair라는 구조체를 하나 만들고, 그 안에!!!!! 우리의 프로토콜 타입의 저장 프로퍼티들이 있네요.


그럼 만들어봅시다.


Pair는 Drawable을 준수하는 모든타입을 받을 수 있죠. 그래서 Line과 Point를 하나의 Pair로 만들어주었습니다.

그럼 existential containers는 어케 만들어지냐??????? 일단 Swift는 2개의 existential containers를 만들고, 이 2개의 existential containers를 Pair 구조체로 감싸 pair의 inline에 저장합니다.



이렇게 되겠죠. 왜 이렇게 되는지는 위ㅏ에서 계속 설명했으니 설명 안하겠음



이렇게도 된다.

근데 Heap할당이 2번........ㅋ


자, 이 Heap할당에 대한 비용을 자세히 알아봅시다.



자, 이런 pair가 있다고 생각해봅시다. 둘다 Line이라서 Heap할당이 2번댔음



근데 copy라는거에 pair를 할당하면...복사가 일어나는데...!!!!!!!!!!

이 Heap도 복사가 됨ㅎ...나름 Line이랑 Point가..struct인걸.....잊지말아주세요..


흠 우리가 이런 노답인 상황은 만들고싶지 않은건 당연하겠죠.....

아니 도대체 왜 이런상황이 일어났을까요ㅎ


왜냐면 Line은...valueBuffer보다 가지고 있는게 더 많았기때문 ㅠ

그래서 이 비싼.. Heap할당이 일어났죠.


그럼 이걸 어떻게 이 상황을 해결 할 수 있을까요?

흠 레퍼런스를 저장하면 어떻게 될까요? 

기본적으로 레퍼런스는 한공간만 차지하기 때문에 valueBuffer에 들어갈 수 있게 됩니다!!!!!!!!!!!!!

즉, Line이 struct가 아니라 class로 정의되면 됩니다. 

==> 엥 어차피 Heap할당 일어나잔ㅎ슴 ==> 대신 위 그림처럼 복사는 안되겠쬬...call by reference이기 때무네..



바로 이렇게 말이죠.

우리가 second에 first를 복사하면



우리가 유일하게 지불해야하는 비용은 레퍼런스 카운트 하나 증가!


그럼 우리가 class를 사용할 때, 늘 생각하는게 있죠. 

reference를 사용할 때는 원본을 같이 공유하기 때문에, 의도하지않은 상태공유가 될 수 있죠.



그래서 second.x1을 3.0으로 바꾸면 first의 x1도 바뀌겠죠.


우리는 이런거는 원하지 않고, value semantics을 원하는데..근대ㅔ...비용은 많이 발생 안됐으면 좋겠구.,,,,,,ㅎ,,,,,ㅎㅎ



이렇게 하구싶음 ㅎ

그럼 여기서 어떻게 더 할 수 있을까요?


copy and write(COW)라는 기술을 통해 이 문제를 해결 할 수 있는데요, 한번 봅시다.



먼저 LineStorage라는 class를 만듭니다. 그리고 Line이라는 구조체가 storage라는 LineStorage타입의 저장 프로퍼티를 가집니다.

즉, 원래 Line안에 직접적으로 있던 저장 프로퍼티들을 LineStorage로 옮겼네요.

 LineStorage는 class이니 참조 하나만 가지고 있음 댐

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


하지만 우리가 막 value를 수정할 때, 먼저 레퍼런스 카운트를 확인합니다. 만약 그게 1보다 크다면, 우리는 LineStorage의 복사본을 만들고 이를 변경합니다.


아니 이게 먼소리에요

일단 예제를 봅시다. LineStorage를 사용하는 예제에요.



아까랑 똑같음 pair를 만들면 아까는 각각 Heap에 할당을 해서 Heap할당이 2번이 됐는데


하지만


LineStorage를 사용하면 이렇게 됩니다. 왜 한곳을 가리키고 있냐!?!?!?!?(위에서는 각각 다른 것을 가리키고 있었음)

왜냐면 storage라는 저장프로퍼티는 class니까요...같은 곳을 가리키고 있게 될 거에요.



역시나 pair를 copy에 할당해도 storag가 class라서...같은 storage를 가리키게 됩니다.


엥;;


나는 이런상황을 원한다니까여

네! 그걸 해주는게 바로 위에 있던  move()코드입니다.



만약 값의 변화가 일어난다면, 뭐 copy의 값을 변경한다고 생각해봅시다. (move메소드를 통해서요 ㅇㅇ)

그럼 move안에서 일련의 조건을 검사하는데, 만약!!!! isUniquelyReferencedNonObjc잖아요? 이 레퍼런스가 유일하냐?? 유일하지 않으면 어딘가에서 이 레퍼런스를 또 쓰고있다는 말이잖아요? 이게 만약에 유일하지 않다면!!!!!!!! == 이 레퍼런스가 어딘가에서 쓰이고 있다면 새로운 storage를 만듭니다. 물론 현재 사용되고 있는 storage와 똑같이요.

그리고 우리는 이 새로 만든 storage에 있는 값을 바꾸게 될 것이므로



이러한 상황이 가능하게 되는것이죠.

ㅎㅎㅎㅎㅎㅎ



자, 그래서 우리는 지금까지 프로토콜 타입의 변수가 어떻게 복사 / 저장되는지, 그리고 method dispatch가 어떻게 작동하는지를 봤어요.



existential container의 inline valueBuffer에 들어갈 수 있는 작은 value의 프로토콜 타입이 있으면 Heap할당이 없고, struct에 레퍼런스가 포함되어 있지 않으면 레퍼런스 카운팅이 없습니다. (엥 당연) ==> 그래서 이는 정말로 빠른 코드에요!



근데도 vwt와 pwt를 통해 dynamic dispatch와 dynamic polymorph behavior까지 항 수 있습니다.





근데 inline valueBuffer에 들어갈 수 없는 큰 value와 비교하면 해당 타입의 변수를 초기화하거나 할당할 때 마다 Heap할당이 발생합니다.

그리고 레퍼런스까지 포함한다면 레퍼런스 카운팅도 하겠죠. (위 그림은 레퍼런스가 없을 때를 이야기 하는듯)



하지만, 이는 위에서 언급되었듯이 indirect storage를 사용한다면 이러한 값비싼 Heap할당을 줄일 수 있죠.




흠 Generic까지 할라그래ㅑㅆ는데 기운이 다빠짐

ㅎㅎ다들 즐거운 추석보내시고 이해가 안가는 부분이나 틀린부분 등..

모두 댓글이나 PC화면 오른쪽 하단에 있는 채널서비스를 이용바랍니다.



다음 편 보기





반응형