longlivedrgn
Miro 찾기
longlivedrgn
전체 방문자
오늘
어제
  • 분류 전체보기 (74)
    • Swift (36)
    • iOS (31)
    • Algorithm (0)
    • Architecture, Design Patter.. (1)
    • Computer Science (6)
      • 컴퓨터 네트워크 (6)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
longlivedrgn

Miro 찾기

[Swift] WWDC16 Understanding Swift Performance(1)
Swift

[Swift] WWDC16 Understanding Swift Performance(1)

2023. 7. 31. 14:03

WWDC16 Understanding Swift Performance를 들으면 정리한 내용입니다.

 

스위프트의 퍼포먼스를 고려하며 코드를 짜기위해서 고민해야되는 부분은 크게 세 가지입니다. 

차근차근 세 가지가 무엇인 지 알아보겠습니다.


Allocation

 

Stack의 경우, LIFO(Last In First Out)구조로써, stack 끝에 포인터가 위치합니다. 우리는 Stack pointer가 가르키는 곳을 줄임으로써 필요한 메모리를 할당하고, 포인터를 증가시킴으로써 메모리를 할당 해제합니다. (위에서 아래로!) 메모리 할당하는 과정에서 Stack pointer는 원래 있던 곳으로 다시 위치하게 됩니다. 

 

Heap의 경우, 훨씬 더 다이나믹하지만 stack에 비해서는 덜 효율적입니다. Heap에 메모리를 할당할 경우, Heap 데이터 구조를 확인하여 할당되어 있지 않은(사용되지 않은) 블럭을 찾아야합니다. 그리고 할당 해제를 하기위해서는 사용된 메모리 블럭을 적절한 자리에 다시 넣어야합니다. 하지만 이 과정에서 제일 크리티컬한 부분은 Heap의 경우 여러 쓰레드에서 동시에 접근하여 메모리를 할당할 수 있기에, lock 및 동기화 메커니즘을 통하여 Thread Safety를 구현해야합니다. 즉 하나의 쓰레드에서만 할당할 수 있도록 해야하는 것입니다. 이 부분이 heap에 할당할 때, 가장 큰 cost를 유발하는 이유 입니다. 

 

 

위의 내용을 코드를 통해서 더 자세하게 알아보겠습니다.

코드를 실행하기 전에 point1, point2 인스턴스에 대해서 stack에 공간을 할당했습니다.

 

 

그리고 실제 인스턴스를 만들 때는 Stack에 먼저 할당된 메모리를 초기화합니다. 그리고 point2의 경우, point1의 복사본을 생성하여 동일한 방법으로 할당 메모리를 다시 초기화합니다. 그리고 point2.x를 5로 바꿔도 point1.x는 변하지 않습니다. 우리는 이것을 Value Semantics라고 부릅니다. 

 

 

그럼 비슷한 로직을 Class로 구현해봅시다.

코드가 실행되기 전에, Stack에 point1과 point2의 메모리가 할당이됩니다. 그리고 Point(x:0, y:0)라는 인스턴스가 생성되면, Swift는 Heap을 lock하고, 적절한 사이즈의 사용되지 않은 메모리 블럭을 찾습니다. 그리고 공간을 찾으면 메모리를 할당합니다. 그리고 point2의 경우, Point 인스턴스 값을 복사하는 것이 아니라, 레퍼런스를 복사합니다. 

그러므로, point2.x 값을 변경하면 point1.x 값도 같이 변하게 됩니다. 우리는 이 과정을 Reference Semantics라고 부릅니다.

 

이렇게 Heap에 메모리를 할당하는 것은 어려모로 cost가 큰 작업입니다. 그리고 class는 heap에 메모리를 할당합니다. 따라서 class의 특징을 사용하지 않을 경우, struct를 활용하여 value semantics를 활용하는 것이 퍼포먼스적으로 더 좋다고 볼 수 있겠네요.

 

 

다른 예를 한번 보겠습니다.

아래와 같이 key 값을 string으로 설정한 코드가 있습니다.

String은 일단 두 가지 문제점이 존재합니다.

➡️ 하드 코딩된 값이므로, 개발자의 실수를 유발할 수 있다.

➡️ String은 Heap에 메모리를 할당하므로, Heap Overhead를 발생시킬 수 있습니다.

 

 

위와 같이 struct으로 구현을 한다면, 위에서 말했던 String을 key 값으로 활용하는 단점들을 해결할 수 있겠네요.

 

 


Reference Counting

그렇다면 Swift는 언제 Heap에 저장된 메모리를 할당해제할까요? Swift는 Heap에 저장된 모든 인스턴스에 대한 참조 카운트를 확인하여 참조 카운트가 0이될 경우, 해당 메모리는 할당해제하기에 안전하다고 판단하여 메모리를 할당해제를 합니다. 즉, 참조하는 인스턴스가 많아지면 참조 카운트를 증가시키고, 적어지면 참조 카운트를 감소시키는 것이죠. 그리고 이러한 작업은 빈번하게 수행됩니다.

 

이러한 작업은 Heap Allocation과 비슷하게 Thread safety 이슈가 존재합니다. 그래서 Atomically하게 reference count를 해야합니다. (여기서 말하는 atomically는 동시에 여러 작업이 수행되더라도, 그 작업들이 하나의 단일 원자적인 연산으로 처리되는 것을 의미합니다. -> 다중 스레드 환경에서 여러 쓰레드가 어떤 작업들을 한번에 할 경우, 단일 작업으로 퉁치는 것!) 그리고 이 과정은 Reference counting을 비싸게 만드는 이유 중 하나입니다. 

 

 

예시를 통하여 알아보겠습니다.

왼쪽과 같은 코드가 존재할 경우, 실제 Swift 컴파일러는 오른쪽과 같이  하이라이트된 코드를 삽입합니다. Pointer Class 안에 reference count라는 프로퍼티를 생성하여 인스턴스가 생성되었을 경우, 몇 번이나 참조가 되고 있는 지를 담고 있습니다. 또한, retain 함수를 통하여 reference count(refCount)를 증가시키고, release 함수를 통하여 reference count를 감소시킵니다. 그리고 모두 다 release가 되어 reference count가 0이 되면, Swfit가 메모리 할당 해제를 하기에 안전하다고 판단하여 메모리를 할당해제합니다. 

 

 

Struct의 경우에는 어떻게 될까요?

아래와 같이 reference count를 체크하지 않습니다. 따라서 reference counting overhead가 발생하지 않죠.

 

 

이번에는 조금 더 복잡한 상황을 보겠습니다.

Label의 프로퍼티 text는 String타입이고, font는 Class입니다. 따라서 위와 같이 retain, release가 일어나면서 reference counting이 진행됩니다. 그리고 프로퍼티마다 retain, release가 일어나므로 각 2번씩 일어나는 것을 알 수 있습니다.

 

여기서 알 수 있는 것은, Label은 struct임에도 불구하고, 안에 있는 프로퍼티 타입의 갯수에 따라서 reference counting을 하는 빈도가 비례해서 증가합니다. 만약 참조 타입의 프로퍼티를 2개 이상 가지고 있을 경우, class보다 더 많이 reference counting을 하게될 가능성이 있습니다.

 

즉, 값 타입인 struct를 쓴다고 reference counting overhead가 안 생기는 것은 아니었습니다.

 

또 다른 예를 한번 봐보겠습니다.

위와 같은 Attachment 구조체가 있다고 가정해봅시다. 해당 구조체는 3개의 프로퍼티를 가지고 있고, 세 개의 프로퍼티 다 String을 받으므로(URL도 init에서 String을 받습니다.) 세 개의 프로퍼티 다 Heap에 저장이됩니다.

 

앞서 알아봤드시, 2개 이상의 레퍼런스가 있을 경우, struct는 class보다 더 많은 reference counting을 하므로, reference counting overhead가 발생하게 됩니다.

 

그렇다면 해당 코드를 어떻게 개선해 볼 수 있을까요?

먼저 UUID 타입을 활용해서 uuid 프로퍼티 값을 설정해줍니다. UUID 타입은 무작위로 선별된 128 비트 식별자를 struct에 직접 저장하기에 Heap에 할당되지 않습니다.

 

 

이번에는 mineType 프로퍼티를 살펴보겠습니다.

기존의 mineType의 경우 위와 같이 구현이 되어있었습니다. 위 코드는 파일이 만약 지원하지 않는 형식이라면 guard문을 통해서 nil을 반환합니다. 

 

 

mineType 타입은 위와 같이 enum MimeType으로 바꿔볼 수 있습니다. 열거형의 경우, case들을 Heap에 저장하지 않기에 이전에 reference counting overhead를 방지할 수 있습니다.

 


Method dispatch

Method dispatch의 사전적 의미는 메세지에대한 응답으로 어떠한 메소드를 결정할 지를 정하는 알고리즘입니다. 

Method dispatch의 종류는 아래와 같이 두 가지가 존재합니다.

➡️ Static dispatch

➡️ Dynamic dispatch

 

Static dispatch의 경우, 메소드가 호출되었을 때, 컴파일 시점에 어떠한 구현을 실행하도록 결정할 수 있는 것을 의미합니다. 이럴 경우, inline과 같은 것을 활용하여 코드를 최적화시킬 수 있습니다.

 

그와 반대로, Dynamic dispatch의 경우, 메소드가 호출되었을 때, 컴파일 시점에 어떤 구현을 실행하도록 결정할 수 없습니다. 런타임시에 실제 실현부로 넘어가서(jump to) 구현을 실행하는 것입니다. 해당 경우는 inlining 및 다른 최적화를 하지 못하게 됩니다.

 

딱봐도 Static dispatch가 Dynamic dispatch에 비해서 비용이 훨씬 적다는 것을 알 수 있겠죠?

 

그렇다면, 앞서 말한 inline은 무엇일까요?

위와 같은 코드가 존재한다고 생각해봅시다.

위 코드는 Static dispatch를 통해서 drawAPoint 함수의 정확한 구현을 컴파일 시점에 판단할 수 있습니다. 그 과정은 아래와 같습니다.

 

 

이렇게 바로 drawAPoint 함수는 위와 같이 point의 draw() 함수로 대체가 되고

 

 

결과적으로는 draw()함수의 구현만을 실행하게 됩니다. 이러한 과정이 inlining이라고 합니다. 

 

단일 Static dispatch는 단일 Dynamic dispatch와 비교해서 큰 차이가 없습니다. 그러나, Static dispatch 체인은 위와 같은 과정을 통해서 전체 체인의 가시성을 확보하게 됩니다. 반면에 Dynamic dispatch 체인은 컴파일러의 가시성을 막고, Static dispatch가 했던 것과 같은 최적화 작업을 할 수 없게 됩니다.

 

 

그렇다면 왜 Dynamic dispatch를 쓰는 것일까요?

그것은 다형성(Polymorphism)과 같은 성질들이 가능하기 때문입니다. (ex. Overriding 및 Overloading과 같은 것들)

 

 

아래와 같은 코드가 존재한다고 가정해봅시다.

위의 코드는 Drawable 클래스를 상속 받는 Point 클래스 인스턴스와 Line 클래스 인스턴스를 담은 drawables 배열을 생성합니다.

 

 

그리고, 배열의 요소들은 각각 heap에 있는 객체들을 가리키고 있습니다. 

 

 

그렇다면 위와 같이 draw() 함수를 호출할 때는 어떤 방식으로 draw()함수를 실행하는 것일까요?

해당 d가 Line인 지, Point 객체인 지 모르는 상태에서 컴파일러는 컴파일 타임에 정확한 구현을 실행할 수 없는데 말입니다.

 

 

컴파일러는 해당 클래스의 타입 정보를 가르키는 포인터를 담고 있는 필드를 생성하고, static memory에 저장합니다. 그래서 draw() 메소드를 호출할 때, 컴파일러가 실제로 수행하는 것은 정확히 실행해야할 구현 부를 가르키는 포인터를 가지고 있는 타입 및 static 메모리의 virtual method table을 조회하는 것입니다.

 

Virtual Method Table을 조금 더 자세하게 알아보겠습니다.

서브 클래스는 함수 포인터를 담고 있는 배열인 각자의 V-table을 가지고 있습니다. 해당 테이블은 서브 클래스가 override한 모든 메소드에 대한 함수 포인터를 가지고 있고, 새롭게 정의된 메소드에 대한 포인터는 해당 배열의 마지막 끝에 추가됩니다. 위의 그림에서 Line.Type이라고 되어있는 것이 V-table입니다. 그리고 draw 메소드는 실제 실행되어야하는 실행부를 포인팅하고 있습니다.

 

그래서 Dynamic dispatch가 일어날 경우, V-table을 조회하는 과정이 한번 더 생기게 됩니다.

 

이렇게 class는 dynamic dispatch를 활용합니다. 그러나 모든 class가 dynamic dispatch가 필요한 것은 아닙니다. 따라서 만약 class가 서브 클래싱될 일이 없다면, final 키워드를 통해서 static dispatch를 할 수 있도록 구현할 수 있습니다.

 

 


위의 내용을 총 정리해보면, Swift 퍼포먼스를 고려하여 코드를 짜기위해서는 아래의 3가지 질문에 답하는 과정이 중요합니다.

1️⃣ 이 인스턴스가 stack에 할당될 까, heap에 할당될까?

2️⃣ 얼마나 많은 reference counting이 일어나게 될까?

3️⃣ 이 인스턴스의 메소드를 호출했을 때, static dispatch가 일어날 까, dynamic dispatch가 일어날까?

저작자표시

'Swift' 카테고리의 다른 글

[Swift] WWDC16 Understanding Swift Performance(3)  (0) 2023.08.03
[Swift] WWDC16 Understanding Swift Performance(2)  (0) 2023.07.31
[Swift] 다형성을 활용하여 Enum 대체하기  (0) 2023.07.26
[Swift] 다형성과 추상화  (0) 2023.07.14
[Swift] IUO(옵셔널 암시적 추출)  (0) 2023.06.12
    'Swift' 카테고리의 다른 글
    • [Swift] WWDC16 Understanding Swift Performance(3)
    • [Swift] WWDC16 Understanding Swift Performance(2)
    • [Swift] 다형성을 활용하여 Enum 대체하기
    • [Swift] 다형성과 추상화
    longlivedrgn
    longlivedrgn

    티스토리툴바