참고자료

WWDC 2016 - Understanding Swift Performance
naljin - Swift의 Type과 메모리 저장 공간
개발자 소들이 - String에 대한 고찰 struct인데 Heap에 저장된다고?
Zedd - Understanding Swift Peformance
개발자 소들이 - Memory Structure(Code, Data, Stack, Heap)

Understanding Swift Performance

Swift에서는 많은 타입 종류가 있으며, 추상화를 할 수 있는 여러 개념(상속, 프로토콜, 제네릭) 들이 있다
하지만 마구잡이로 사용하면 안되는데, 왜냐하면 이것들이 성능에 많은 영향을 끼치기 때문이다

Agenda

  1. Allocation
  2. Reference Counting
  3. Method dispatch
  4. Protocol types
  5. Generic code

일단은 성능을 측정하는 방식을 3가지로 두고 비교하겠습니다.

  • 첫 번째 그림은 메모리 구조 중 할당에 대한 내용인데, Stack에 할당하는 것이 Heap에 할당하는 것에 비해 성능면에서 좋다는 것이며,
  • 두 번째 그림은 Reference Counting이 적으면 적을 수록 좋다는 것
  • 세 번째 그림은 Static Method Dispatch가 Dynamic Method Dispatch에 비해 좋다는 것입니다.

Allocation

첫 번째 그림에서 Stack과 Heap을 비교하고 있는데, 메모리 할당과정이 어떻게 이뤄지는지 알아보겠습니다.

Heap에 메모리를 할당하려면, 실제로 Heap 데이터 구조를 검색하여 사용되지 않는 적절한 크기의 블록을 찾아야만 합니다..

그리고 모든 작업이 끝나고, 할당을 해제하려면 해당 메모리를 또 적절한 위치로 다시 삽입해야 합니다.. 실제 이런 과정이 크게 영향을 끼치지는 않지만 미미하게 비용에 영향을 미칩니다.

가장 큰 복잡한 비용은, 여러 쓰레드가 동시에 Heap에 메모리를 할당할 수 있기 때문에, Heap은 locking 또는 기타 동기화 매커니즘을 사용하여 무결성을 보호해야 한다는 점입니다. 이게 Heap할당에서 가장 큰 비용이라고 할 수 있습니다.

반면, Stack은 LIFO 구조로 매우 단순한 데이터 구조입니다. Stack의 마지막, 즉 TOP으로만 Push가 가능하며, 역시 TOP에서만 POP이 가능합니다.

그러니, Stack 끝에 포인터만 유지하면, Push 및 Pop을 구현할 수 있게 됩니다. 이 포인터를 Stack 포인터라고 부릅니다.

그래서, 우리가 함수를 호출 할 때, 우리는 메모리를 일단 먼저 할당해야 하는데, 메모리를 단순히 Stack 포인터가 가리키고 있는 곳을 단순히 줄임으로써 필요한 메모리를 할당할 수 있습니다. 그리고 함수가 끝나면, Stack 포인터를 줄이기 전에 있던 곳으로 증가시킴으로써 그 메모리 할당을 해제할 수 있습니다.

Stack 영역
Stack 영역은 메모리의 높은 주소에서 낮은 주소의 방향으로 할당됩니다. 함수의 호출과 관계되는 지역 변수와 매개변수가 저장되는 영역입니다. 스택 영역의 크기는 컴파일타임에 결정된다고 합니다.

위의 그림을 보면, struct 타입으로 만들어진 인스턴스 Point객체가 스택 영역에 올라간 것을 볼 수 있습니다.

point1point2로 할당해주는데, 복사본을 만들어서 다른 메모리에 다시 초기화 되는 것을 볼 수 있습니다.

다음 이미지에서 아직 런 타임 이전이라 Heap 영역에 아무것도 없는 것을 볼 수 있습니다.

이제, 우리가 Point(x: 0, y: 0)이라는 인스턴스를 만들 것인데, Swift는 Heap을 lock하고(무결성을 위해) 해당 크기의 메모리 블록을 검색할 것입니다.

이후, class 타입으로 만들어진 인스턴스 Point가 Heap영역에 올라갔으며, 파란색 부분은 인스턴스를 저장하는 공간 이외에도 Type 정보, Reference count, 힙 영역의 크기가 저장되는 공간입니다.

이후, 실행이 끝나면 Swift는 Heap을 잠그고(lock) 사용하지않은 블록들을 적절한 위치로 재삽입합니다.

여기서 잠깐!!

성능상의 이점을 가져오기 위해서 한 가지 팁을 알아봅시다!

위의 key값은 String 타입으로 Struct 타입이지만 가변성이라는 특징 때문에 Heap 영역에 Allocation되기도 합니다. 그럼 Stack 영역에 할당되도록 하기위해서는?

Color, Orientation, Tail 각 요소들을 모두 값 타입인 enum 타입으로 정의하여, key로 사용하게 되면 Heap 영역이 아닌 Stack 영역에서 Allocation 할 수 있게 됩니다.

이제 메모리 할당에 대한 오버헤드가 발생하지 않게 되었습니다!


Reference Counting

Swift에서는 ARC(Automatic Reference Counting)을 통해서 Heap 영역에 메모리를 할당받는 참조 타입들의 참조 횟수를 관리합니다.

위에서 Reference Counting이 Swift Performance에 영향을 미친다고 했으니, 그럼 한 번 알아보도록 하겠습니다.

  • 우리가 Reference Counting을 관리하는 것은 단순하게 참조 횟수를 올리거나, +1, -1 하는 것 이상으로 빈번히 수행됩니다.
  • 또 중요한 것은, Heap할당과 마찬가지로, 레퍼런스가 여러 쓰레드에 의해 동시에 추가 / 제거 될 수 있기때문에 Thread safety를 고려해야합니다.

왼쪽은 실제 코드이고, 오른쪽은 컴파일러에 의해서 코드가 추가된 것입니다. Reference Count가 올라가고, 할당, 해제되는 과정이 일어납니다.

꽤나 귀찮은 과정들이 추가된 것을 볼 수 있겠죠??

그렇다면 무턱대고 Struct를 사용하면 되는 것인가??

아닙니다. 더 최악일 수 있는데, Heap영역에서 Reference Count가 관리되어야 하는 String, UIFont 타입이 프로퍼티로 있을 경우, 아무리 Struct타입이라고 하더라도 레퍼런스 수에 비례하여 레퍼런스 카운팅 오버헤드를 지불하게 되며,
둘 이상의 레퍼런스가 있는경우, class보다 레퍼런스 카운팅 오버헤드가 더 많이 발생하게 됩니다.

충격이죠?

사실 이제껏 class, struct만 두고 생각을해서 무조건 struct가 좋다라고 생각했는데...

이렇게 많은 reference들을 포함하고 있는 struct의 성능이 Reference Counting 에서는 최악일 수도 있다는 점...

그럼 이를 해결할 수 있는 방법은?

Attachment 타입의 경우 현재 프로퍼티가 모두 Heap 영역에 할당되는 타입이기에 Reference Counting Overhead가 많이 들고 있습니다

지금처럼 UUID 타입으로 uuid를 변경하고, mimeType을 열거형 타입으로 생성해서 변경하면??
Reference Counting Overhead가 많이 줄어들겠죠?

생각보다 타입설계 시에 고려해야되는 문제들이 많은 것 같습니다.


Method Dispatch

Dispatch는 다형성와 밀접한 관계를 갖는데, 어떤 인스턴스 메소드를 사용할지를 고르는 과정이라고 생각하면 쉽습니다.

Static Dispatch

  • 바로 점프를 해서 런타임에 사용한다
  • inlining와 다른 최적화를 진행

inlining
컴파일 타임에 호출될 메소드를 찾아가서 코드를 복사해두는 것, 원래 같으면 런타임에 찾아가서 코드를 가져와야 한다. 실행 속도를 많이 아낄 수 있다


Dynamic Dispatch

  • 런타임시에 테이블에서 구현을 조회
  • 그리고 구현 부로 점프 한다
  • inlining과 다른 최적화를 진행하지 못한다

위 그림을 보면 상속을 통해 다형성이 이뤄지고 있는데, drawables 배열 내에 있는 Element로 draw() 메소드가 수행될 때 우리는 Dynamic Dispatch를 통해 해당 메소드를 알아내야 된다.

위 그림과 같이 런타임에 drawables 배열 내의 요소들이 어떤 타입인지 알아냈다고 해보자! Line 타입인 것을 알게 되었다면, 우리는 Line 타입의 vtable로 이동한다. v-table이란 함수 포인터들의 배열로 표현되어 있는 vtable을 말하며, Swift 에서는 Dynamic Dispatch를 위해서 클래스마다 vtable(Virtual Dispatch Table)을 유지한다.

vtable는 함수 포인터들의 배열로 표현되며, 하위 클래스가 메소드를 호출할 때 이 배열을 참조하여 실제 호출할 함수를 결정합니다. 이 모든 과정이 런타임에 결정되기 때문에 Static Dispatch에 비하면 추가적인 연산이 필요할 수밖에 없고, 컴파일러가 최적화 할 여지도 많지 않습니다.

메소드 호출
메소드를 호출한다는 것은 해당 메소드 reference를 통해서 해당 메소드가 존재하는 메모리로 가서 코드를 실행시켜준다는 것이다.

그러니깐 class를 사용해서 상속 개념을 통해 다형성을 이루면 Static Dispatch가 아닌 Dynaimc Dispatch가 일어나, inlining과 최적화를 컴파일 타임에 못이루고, 사용할 메소드를 런타임에 정하게 되어 성능상 좋지 못하다


Protocol에서의 Dispatch

하지만 struct라고 해서 항상 static dispatch가 일어나는 것은 아닌데, 왜냐하면 다형성 중에서 프로토콜을 통한 다형성 때문이다.

프로토콜은 구현제를 제공하지 않고, 선언부만 제공한다. 물론 extension을 통해서 기본 구현을 해줄 수 있어서, 예외의 경우가 있긴하다. 일단은 프로토콜을 채택한 타입은 이를 구현해야 하며, 프로토콜을 통해 호출하는 메소드는 프로토콜을 채택한 타입들이 실제로 구현한 메소드들이다. 그런데 프로토콜 타입의 참조로만 이들을 사용해야 한다면, 해당 인스턴스의 타입에 맞는 메소드를 호출해야만 한다. 이를 위해서 프로토콜은 프로토콜 타입만을 위한 vTable을 가지게 된다. 이를 Witness Table(목격자 테이블)이라고 한다. 즉, 프로토콜 역시 Dynamic Dispatch를 사용한다.


Understanding Swift Performance 뒤에 내용이 더 남긴 했지만, 다음 시간에.... 계속

profile
iOS Developer Student

0개의 댓글