Swift에서 "Abstration(추상화) 메커니즘이 성능에 미치는 영향"을 이해하기 위해

가장 좋은 방법은 기본 구현을 이해하는 것이다.

 

Abstration 메커니즘을 설계하고, 선택할 때 다음과 같이 3가지를 고려해야 한다. 

  • Allocation
  • Reference Counting
  • Method Dispatch

이 3가지 요소들을 하나하나 살펴보도록 하자.

 

 

Allocation (Stack  vs. Heap)

Swift는 자동으로 메모리를 할당하고, 해제한다. 

이 중 일부는 Stack에 저장하기도 Heap영역에 저장한다. 

 

Stack 

"Stack"은 LIFO구조

Static하게 메모리를 할당할 수 있는 매우 단순한 Data Structure이다. 

 

Top에서만 데이터의 삽입(Push)과 삭제(Pop)가 가능하기 때문에, 

Top에 대한 포인터인 "Stack Pointer"만 가지고 있으면 Push와 Pop이 가능하다.

 

Push의 경우, Stack Pointer를 감소시키고 할당하면 된다. 

반면 Pop과 같은 경우는 Stack Pointer를 증가시킴으로써 메모리를 해제할 수 있다.

 

Heap

"Heap"은

Dynamic하게 메모리를 할당할 수 있는 조금 더 복잡한 Data Structure이다.

 

Heap에서의 메모리 할당은, 

Heap Data Structure에서 사용 가능한 적절한 Size를 가진 블럭을 찾아야 한다. 

반면, 메모리 해제는 

해당 메모리를 적절한 위치에 다시 삽입해야 한다.

 

또한, Multi Thread의 경우 Heap 영역에 동시에 접근할 수 있다.

해당 경우 데이터의 불일치 등 여러 추가적인 문제가 발생할 수 있기 때문에, 

Heap Data Structure은 "locking"과 같은 Synchronization Mechanism이 필요하다.

사실 이게 Heap할당에서 발생하는 가장 큰 비용이다.

 

Struct - Stack 

다음과 같이 2개의 Stored Property와 한 개의 메서드를 가지고 있는 Struct가 있다고 가정하자. 

우선, 해당 함수에 진입했을 때, 

즉! 아래의 코드를 수행하기 전에, 

Swift는 point1point2 instance를 위한 영역을 Stack에 할당한다.

 

Struct는 Value Type이기 때문에, 

Stored Property는 Stack에 저장되므로,

이를 위한 영역을 고려하여 Struct Type의 instance를 Stack에 할당한다.

 

위와 같이 

let point1 = Point(x: 0, y: 0)이 수행이 되면,

이미 Stack영역에 할당된 메모리를 initialization을 하게 된다.

 

point2의 경우는 point1의 메모리 영역을 copy하여 initialization하게 된다. 

이 둘은 independent하기 때문에, 

point2에서 Property의 값을 변경에도 point1에는 영향을 미치지 않는다.

 

해당 함수의 실행이 완료되면 Stack pointer를 증가시킴으로써 

point1point2를 위한 메모리 영역을 해제되게 된다.

 

Class - Heap

다음은 Class의 경우를 살펴보자.

해당 함수에 진입했을 때, 

마찬가지로 point1point2 Instance가 아닌! 변수에 대한 영역을 Stack에 할당한다.

 

하지만, Class의 경우 Reference Type이기 때문에, 

Stored Property에 대한 영역을 메모리에 할당하지 않고, 

Reference에 대한 영역을 할당한다.

 

해당 코드에 진입하게 되면,

Swift는 Heap을 다른 Thread에서 접근하지 못하도록 lock하고,

적절한 Size의 memory 블럭을 찾게 된다. 

 

이후, Heap에 할당된 메모리에서 Instance를 Initialization을 하고,

Stack의 영역에선 해당 Heap 영역에 대한 Reference를 저장한다.

 

또한 Swift는 Heap 영역에 파란색 영역의 2 Word의 추가적인 공간을 할당하는데, 

이는 해당 Instance를 Swift가 관리하기 위해 사용되는 공간이다.

 

point2의 경우는 point1의 Reference를 저장하게 된다. 

즉! 둘은 같은 Heap 영역의 instance를 가리키게 된다. 

 

따라서, point2의 Property값이 변경되면, 

point1에서도 반영이 된다.

 

해당 함수의 동작을 마치게 되면, 

다시 Heap을 lock하고 해당 메모리를 해제하고,

Block들을 적절한 위치로 재삽입한다.

Heap에서의 동작이 완료되면 Stack을 Pop한다.

 

Summary

Struct의 경우 Stack영역만 사용하지만, 

Class의 경우는 Heap의 할당이 필요로 하기 때문에

비용이 더 많이 든다.

Heap은 동기화 메커니즘과 적절한 메모리 블럭을 찾아야 하기 때문에, Stack에 비해 비용이 비싸다. 

Class에는 ID 혹은 Indirect Storage와 같은 특성이 있지만, 

Abstration 과정에서 해당 특성이 필요로 하지 않는다면 

Struct를 사용하는 것이 성능면에서는 이점이 있다. 

 

다음 코드를 앞서 살펴본 대로 수정해 보자!

해당 코드는 UI Layer에서 동작하기에 좀 더 빠른 동작을 위해 cache를 사용한다. 

하지만 Swift의 "String"은 각 Character들의 Content들을 간접적으로 Heap영역에 저장하기 때문에, 

Chache Hit가 발생하더라도 Heap영역에 접근하는 문제가 발생한다. 

 

또한, String타입에는 "dog"와 같은 상관없는 값도 저장될 수 있기 때문에

key값으로 String은 강력한 타입이 아니다.

 

Key값을 Struct로 대체하게 된다면, 

Heap Allocation이 필요 없게 된다. 

또한, "dog"와 같은 잘못된 키값을 방지할 수 있게 된다.

 

 

Reference Counting

Swift에서 Heap의 할당된 메모리를 해제하는 것이 "Safe"하다는 것을 알 수 있을까?

이는 "Reference Counting"을 통해 가능하다. 

"Reference Counting"은 Heap에 할당된 instance에 대한 Reference 횟수를 저장한다. 

 

만약 Reference Counting이 0이 되면 참조하는 변수가 없다는 것을 의미하며, 

이는 Heap 영역에 해당 인스턴스가 할당된 메모리를 해제하는 것이 "Safe"하다는 것을 의미한다.

 

Swift에선 ARC를 통해 Instance의 Reference Counting을 추적하며,

0이 되는 시점에 해당 Instance가 할당된 Heap영역의 메모리를 해제하게 된다.

 

이 Reference Counting은 단순히 +1, -1을 하는 작업이지만, 빈번히 수행된다. 

또한, Heap instance에 대해 여러 Thread에 대해 접근할 수 있기 때문에, 

Heap Allocation때처럼 Synchronization Mechanism이 필요하다.

 

따라서, Atomically하게 Reference Counting연산이 이루어져야 하고, 

이 연산은 빈번히 수행되기 때문에 비용은 더욱 증가하게 된다. 

 

예시를 통해 Swift에서 어떻게 Reference Counting을 관리하는지 살펴보자.

우리가 왼쪽처럼 코드를 작성하게 되면, 
Swift Compiler는 오른쪽과 같이 코드를 추가하게 된다. 

 

해당 코드의 동작을 살펴보자.

해당 함수에 진입하게 되면 Swift는 point1point2에 대한 메모리를 Stack영역에 할당하게 된다.

둘 다 Reference Type이기 때문에, Heap영역의 Instance에 대한 Pointer가 저장될 공간을 할당한다. 

 

이후 Instance를 생성하게 되면, 

Heap영역에 해당 Instance에 대한 메모리 공간을 할당하고 Initialization을 하고, 

Reference Count를 1 증가시키게 된다.

 

해당 동작에선 단순히 point2는 point1이 가리키는 Heap Instance를 가리키게 된다. 

여기서 중요한 점은 아직 Reference Count는 증가하지 않는다. 

 

Heap Instance의 Reference Countretain 호출을 통해 증가하게 된다. 

해당 동작은 Heap영역에 접근하기 때문에 Atomic하게 진행된다.

 

또한, point1의 release가 이루어지면, Reference Counting은 1 감소하게 되고, 

point1은 더 이상 Heap Instance를 가리키지 않게 된다.

마찬가지로 Heap영역에 접근하기 때문에, Atomic하게 수행되어야 한다.

 

마지막으로 point2의 release가 이루어지면, 

Heap Instance의 Reference Counting은 0이 된다.

 

그러면 Swift는 더 이상 해당 instance가 필요 없다는 것을 인지하고 

Heap을 "lock"하고 메모리를 해제하게 된다. 

이후 Stack영역의 메모리들이 해제된다.

 

Structs Contain Reference Type

Struct는 Value Type이기 때문에 Reference Counting에 대한 연산이 필요 없다.

하지만, Reference Type을 포함하는 경우 Reference Counting에 대한 연산이 이루어진다.

Label은 Struct타입이기 때문에 Stack Instance에 Property에 대한 영역이 할당된다.  

Label에는 다음과 같은 Struct는 String, UIFont타입을 가지고 있다.

두 프로퍼티들을 Heap Allocation이 일어나고, Reference Counting도 계산해야 한다. 

String은 각 Character의 Content를 Heap영역에 저장하고, UIFont는 Class 타입이다.

 

다음으로 let label2 = label1의 코드가 수행되면, 

label2의 Property는 label1의 Property와 같은 Reference를 같게 된다.

 

따라서, Swift의 경우에는 각각의 Property들에 대해 다음과 같이 Tracking하게 된다.

 

위와 같이 Struct안에 Reference Type이 있는 경우는, 

Reference 수에 비례하여 Reference Counting 오버헤드가 증가하게 된다.

 

Summary

Class는 Struct와 달리 Reference Type이기 때문에, 

Heap Allocation, Reference Counting으로 인해 더욱 비용이 많이 든다.

따라서, 특별한 경우가 아니라면 Struct가 성능적으로 더 좋다.

 

하지만 내부에 Reference Type이 많은 경우라면, 

Struct는 이에 비례하여 Reference Counting에 대한 오버헤드가 증가하기 때문에, 

해당 경우라면 Class가 성능적인 면에서 더 좋다.

 

 

Method Dispatch

Runtime에 Method가 호출된다면 Swift는 정확한 Implementation을 실행할 필요가 있다.

이러한 Method Dispatch에는 크게 2가지가 있다.

  • Static Dispatch
  • Dynamic Dispatch

 

Static Dispatch

실행할 함수의 Implementation의 Compile Time에 결정할 수 있는 경우,

이를 "Static Dispatch"라 한다. 

 

Static Dispatch는 Runtime에 바로 올바른 Implementation으로 jump할 수 있게 된다.

 

Swift Compiler는 실제로 어떤 Implementation을 수행할지 볼 수 있기 때문에,

"inline"과 같은 Optimization을 수행할 수 있다.

 

Optimization - Inline

다음과 같이 drawAPointPoint Struct의 draw 메서드를 호출한다.

 

Swift Compiler는 drawAPoint함수의 Implementation을 Compile Time에 알 수 있기 때문에, Static Dispatch가 된다.

따라서, drawAPointImplementation으로 대체한다.

이렇게 Compiler가 Implementation으로 대체하는 것을 "inline"이라 한다.

 

Point의 draw메서드 역시 Static Dispatch되기 때문에, 

아래와 같이 해당 메서드의 Implementaion으로 대체되는 "inline"이 수행된다.

 

원래라면 Runtime에 해당 함수에 진입하면 Call Stack을 통해 해당 메서드로 이동해야 한다. 

 

하지만 "inline"은 Runtime에 Call Stack을 통해 메서드로 이동할 필요가 없이!

inline된 실제 Implementation을 바로 수행하기 때문에, 더욱 빠르다.

 

Dynamic Dispatch

Compile Time에 어떤 Implementation을 수행할지 알 수 없을 경우

"Dynamic Dispatch"이다.

따라서, Runtime때 실제 Implementation으로 직접 이동하게 된다. 

 

Compiler가 어떤 Implementation을 수행할지 모르기 때문에,

Compiler의 Visiability를 차단하고, 이는 "inline"과 같은 Optimization을 수행할 수 없게 된다.

즉, Call Stack으로 인한 오버헤드가 발생한다. 

 

Single Static Dispatch와 Single Dynamic Dispatch를 비교한다면,

많이 차이 나지 않을 수 있지만,

Chain이 많아질수록 이 차이는 Call Stack으로 인한 오버헤드는 점점 차이가 나게 될 것이다.

 

Polymorphism

이러한 Dynamic Dispatch는 Overhead가 발생한다 하더라도, 

"Polymorphism(다형성)"을 활용할 수 있다는 장점이 있다.

 

다음과 같은 전통적인 OOP의 코드를 살펴보자!

여기서 Drawable Class는 Abstract Class로서

이를 상속하는 Point, Linedraw메서드를 override하게 된다.

 

또한, LinePoint의 Property갯수가 다르기 때문에 Size가 다르다.

var drawables: [Drawable]

하지만 Array로 저장될 수 있는 이유는 각 Element에 이들의 Reference를 저장하기 때문이다.  

 

V-Table

for d in drawables {
	 d.draw()
}

여기서 수행되는 draw메서드는 Line 혹은 Point의 메서드인지

Compile Time에 알 수 없기 때문에,  Dynamic Dispatch이다. 

 

그렇다면 어떻게 Swift는 Runtime에 어떤 Implementation을 수행할 수 있을지 알 수 있을까?

이는 "V-Table(Virtual Method Table)"을 통해 알 수 있다.

Swift의 Compiler는 Compile time에 V-Table을 생성하여 이를 Static 메모리에 저장한다.

 

"V-Table"은 각 Type에 대한 Method Implementation에 대한 Pointer와 같은 Type Information을 포함한다.

또한, Heap Instance는 이 V-Table에 대한 Pointer를 가지고 있다

 

따라서, Runtime에 Heap Instance의 Pointer를 통해 draw 메서드의 실제 구현부에 대한 Pointer를 얻게 된다. 

이후 Call Stack을 통해 해당 Implementation으로 이동하게 된다.

 

Performance Summary

결과적으로 Class의 경우

Heap Allocation과 Reference Counting으로 인한 오버헤드가 발생한다.

 

또한, Class는 Dynamic Dispatch이기 때문에, 

"inline"과 같은 Optimization이 불가능하게 된다.

 

하지만, Subclassing이 필요하지 않는 경우 Dynamic Dispatch할 필요가 없게 된다. 

해당 경우엔 final키워드를 사용하게 되면

Compiler는 해당 Class를 Static Dispatch하게 된다.

 

따라서 우리가 성능을 고려하기 위해 다음과 같은 것들을 고려해야 한다. 

  • Instance가 Stack / Heap 어디에 할당될지
  • Instance가 얼마나 많은 Reference Counting이 발생하는지
  • Instance의 Method의 Static Dispatch / Dynamic Dispatch 되는지

 

 

Reference 

WWDC2016 - Understanding Swift Performance

복사했습니다!