Understanding Swift Performance - 번역

Coden·2022년 1월 4일
11

WWDC

목록 보기
1/1
post-thumbnail

아래 글은 WWDC 2016 - Understanding Swift Performance 영상을 한글로 번역 / 의역한 것입니다. 오역 및 잘못된 내용이 있다면 피드백 부탁드립니다.

캡처된 그림 및 사진은 모두 해당 WWDC의 Presentation Slides (PDF)에서 가져온 것입니다.

이미지에 width 속성을 적용하였으나 반영이 되지 않아 크게 보일 수 있습니다.

 


Understanding Swift Performance

이 고급 세션에서는 구조체, 클래스, 프로토콜 및 제네릭이 Swift에서 구현되는 방법을 알아본다. 다양한 성능 차원에서 상대적 비용에 대해 알아보도록 하자. 그리고 이 정보를 적용하여 코드 속도를 높이는 방법을 알아보자.

  • Swift의 추상화 메커니즘 성능 영향을 이해하는 가장 좋은 방법은 기본 구현을 이해하는 것이다.

 

Dimensions of Performance

  • 추상화를 building하고 추상화 메커니즘을 선택할 때 아래의 질문들을 해봐야 한다.

    • 내 인스턴스가 스택에 할당될까 에 할당될까?

    • 이 인스턴스를 전달할 때 얼마나 많은 참조 카운팅 오버헤드가 발생할까?

    • 이 인스턴스에서 메서드를 호출하면 정적으로 dispatch될까 동적으로 dispatch될까?

  • 빠른 Swift 코드를 작성 하려면 활용하지 않는 dynamism과 runtime에 대한 비용을 지불 하지 않아야 한다.

  • 그리고 우리는 더 나은 성능을 위해 서로 다른 차원 간에 언제 어떻게 거래할 수 있는지 배워야 한다.

 

Allocation

  • Swift 는 사용자를 대신하여 자동으로 메모리를 할당 및 할당 해제한다.

  • 스택은 정말 간단한 데이터 구조이다. 스택의 끝으로 push하고 스택의 끝에서 pop할 수 있다.

    • 스택 끝에서만 추가하거나 제거할 수 있기 때문에, 스택 끝에 포인터를 유지함으로써 스택을 구현하거나 푸시 및 팝을 구현할 수 있다.

    • 그리고 스택 끝에 있는 포인터를 스택 포인터라고 부른다.

    • 함수를 호출할 때 공간을 만들기 위해 스택 포인터를 약간 감소시키는 것만 으로 필요한 메모리를 할당할 수 있다.

    • 그리고 함수 실행이 끝나면 스택 포인터를 이 함수를 호출하기 전의 위치로 다시 증가시켜 메모리 할당을 간단하게 해제할 수 있다.

    • 이 스택 할당은 정말 빠르다.

  • 힙은 스택보다 동적이지만 효율성이 떨어진다 .

    • 힙은 동적 lifetime으로 메모리를 할당할 수 있지만 스택은 이것이 불가능하다.

    • 하지만 이를 구현하기 위해서는 더 고급 자료 구조가 필요하다.

    • 힙에 메모리를 할당 하려면 적절한 크기의 '사용되지 않은 블록'을 찾아야 한다.

    • 그리고 작업이 끝난 뒤 할당을 해제 하려면 해당 메모리를 적절한 위치 에 다시 삽입해야 한다.

    • 힙에는 스택보다 더 많은 것이 관련되어 있다.

    • 여러 스레드가 동시에 힙에 메모리를 할당할 수 있으므로 힙은 lock 또는 기타 동기화 메커니즘을 사용하여 무결성을 보호해야 한다. 그리고 이것은 꽤 큰 비용이 된다.

      → 스택은 쓰레드별로 구성되므로 신경쓰지 않아도 된다.

 

일부 코드를 추적하여 Swift가 우리를 대신하여 무엇을 하는지 살펴보자.

  • 여기에 x 및 y 저장 프로퍼티가 있는 Point 구조체가 있다.
  • draw 메서드도 있다.
  • (0, 0)로 point1을 구성하고 point1point2에 할당하여 복사본을 만든 뒤 값 5 를 point2.x에 할당한다 . 그런 다음 point1을 사용하고 point2를 사용한다.

 

  • 이 함수에 들어왔을 때 코드 실행을 시작하기 전에 point1인스턴스와 point2인스턴스를 위해 스택에 공간을 할당했다 .
  • 그리고 point는 구조체이기 때문에 x 및 y 속성은 스택의 라인에 저장된다.

 

  • x가 0이고 y가 0인 point1을 구성할 때 스택에 이미 할당한 메모리를 초기화하기만 하면 된다.

 

  • point1point2에 할당할 때, point의 복사본을 만들고 우리가 이미 스택에 할당했던 point2메모리를 초기화한다.
  • point1point2는 독립적인 인스턴스이다.

 

  • 즉, point2.x에 5라는 값을 할당하면 point2.x는 5이지만 point1.x는 여전히 0이다.
  • 이것을 value sementic이라고 한다.

 

  • 그런 다음 point1을 사용하고 point2를 사용하면 함수 실행이 완료된다.
  • 함수를 입력했을 때의 위치로 스택 포인터를 다시 증가시켜 point1 및 point2에 대한 메모리 할당을 간단하게 해제할 수 있다.

 

이번에는 Class를 써보자

  • 이것을 클래스로 만든 코드와 대조해 보자.

 

  • 함수에 진입하면 이전과 마찬가지로 스택에 메모리를 할당한다.
  • 그러나 point프로퍼티에 값을 저장하는 대신 point1point2에 대한 참조 메모리를 할당할 것이다.

 

  • 힙에 할당할 메모리에 대한 참조.
  • (0, 0) point를 구성할 때 Swift는 힙을 lock하고 해당 자료 구조 에서 적절한 크기의 사용되지 않은 메모리 블록을 검색한다.

 

  • 그런 다음 x가 0이고 y가 0인 메모리를 초기화하고 힙의 해당 메모리에 대한 메모리 주소로 point1 참조를 초기화한다 .

  • 힙에 할당할 때 Swift는 실제로 클래스 Point에 4 word의 스토리지를 할당했다.

  • 이것은 Point가 구조체일 때 할당된 2 word와 대조적이다.

  • 이것은 이제 Point가 클래스이고 x 및 y에 대해 저장된 것 외에 Swift가 우리를 대신하여 관리할 두 word를 더 할당 하기 때문이다.

    • 힙 다이어그램에서 파란색 상자로 표시된다.

       

  • point1point2에 할당 할 때 point1이 구조체였을 때처럼 point의 내용을 복사 하지 않는다.
  • 대신 참조를 복사한다.
  • 따라서 point1point2는 실제로 힙에 있는 동일한 point 인스턴스를 참조한다.

 

  • 즉, point2.x에 5라는 값을 할당하면 point1.xpoint2.x 모두 값이 5가 된다.
  • 이것을 reference sementic이라고 하며 의도하지 않은 상태 공유로 이어질 수 있다.

 

  • 그런 다음 point1을 사용하고 point2를 사용하면, Swift가 힙을 lock한 뒤 사용하지 않는 블록 을 적절한 위치로 돌려놓으면서 이 메모리 할당을 해제한다 .

 

  • 그런 다음 스택을 팝한다.

 

알 수 있는 것

  • 클래스는 힙 할당이 필요하기 때문에 클래스가 구조체보다 구성하는 데 더 많은 비용이 든다는 것을 알 수 있다.
  • 클래스는 힙에 할당되고 refernce sementic 체계를 갖기 때문에 클래스에는 ID 및 간접 저장과 같은 몇 가지 강력한 특성이 있다.

 

  • 그러나 추상화에 이러한 특성이 필요하지 않은 경우 구조체를 사용하는 것이 좋다.
  • 그리고 구조체는 클래스처럼 의도하지 않은 상태 공유에 취약하지 않다.

 

이를 적용 하여 일부 Swift 코드의 성능을 향상시키는 방법을 살펴보자.

  • 다음은 작업한 메시징 앱의 예이다.
  • 기본적으로 이것은 view layer에서 나온 것이다.

 

  • 유저들이 문자를 보내면 그 문자 뒤에 예쁜 풍선 이미지를 그리고 싶다.
  • makeBalloon 함수는 이 이미지를 생성 하는 것이며 이 함수는 다른 풍선의 다른 configuration 또는 전체 configuration space를 지원한다.
  • 예를 들어, 우리가 보는 이 풍선은 오른쪽 방향과 꼬리가 있는 파란색이다.

 

  • 그리고 왼쪽 방향과 거품 꼬리가 있는 회색 풍선도 지원한다.
  • makeBalloon 함수는 allocation launch 및 사용자 스크롤 중에 자주 호출하기 때문에 정말 빨라야 한다.

 

  • 그래서 이 캐싱 레이어를 추가했다.

  • 따라서 주어진 configuration에 대해 이 풍선 이미지를 두 번 이상 생성할 필요가 없다 .

  • 한 번 생성했다면 캐시에서 꺼내기만 하면 된다.

  • 캐시 작업을 위해 색상, 방향 및 tail을 문자열인 키로 직렬화 하였다.

  • 이제 여기에 마음에 들지 않는 몇 가지 사항이 존재한다.

 

  • 문자열은 이 키에 대해 특별히 강력한 타입이 아니다.

  • 이 configuration space를 나타내기 위해 사용하고 있지만 해당 키에 강아지 이름을 쉽게 넣을 수 있다.

  • 그래서 그다지 안전하지 않다.

  • 또한 String은 실제로 힙에 간접적으로 해당 문자의 내용을 저장한다.

  • 따라서 이 makeBalloon 함수를 호출할 때마다 캐시 hit가 있더라도 힙 할당이 발생한다. (키를 만드는 과정에서의 힙 할당)

 

개선해볼 수 있을까?

  • Swift에서 우리는 구조체를 사용하여 색상, 방향 및 tail의 구성 공간을 나타낼 수 있다.

  • 이것은 String보다 이 구성 공간을 나타내는 훨씬 안전한 방법이다.

  • 그리고 구조체는 Swift의 일급 클래스 타입이기 때문에 dictionary에서 키로 사용할 수 있다.

    Hashable 준수 필요

 

  • 이제 makeBalloon 함수를 호출할 때 캐시 hit가 있는 경우 이 Attribute와 같은 구조체를 구성하는 데 힙 할당이 필요하지 않기 때문에 할당 오버헤드가 없다.
  • 이것은 스택에 할당될 수 있다.
  • 이것은 훨씬 더 안전하고 훨씬 더 빠를 것이다.

 

Reference Counting

  • 성능의 다음 차원인 reference counting으로 넘어가 보자.

  • 방금까지는 힙 할당에 대해 이야기할 때 세부 사항을 생략했었다.

  • Swift는 힙에 할당된 메모리를 언제 할당 해제하는 것이 안전한지 어떻게 알까?

  • 대답은 Swift가 힙의 모든 인스턴스에 대한 총 참조 수(reference count)를 유지한다는 것이다.

  • 그리고 인스턴스 자체에 유지한다.

  • 참조를 추가하거나 참조를 제거하면 해당 참조 카운트가 증가하거나 감소한다.

  • 그 수가 0에 도달하면 Swift는 아무도 더 이상 힙의 이 인스턴스를 가리키지 않는다는 것을 알고 해당 메모리를 할당 해제하는 것이 안전하다는 것을 알게 된다.

  • 참조 카운팅에서 염두에 두어야 할 핵심 사항은 이것이 매우 빈번한 작업이며 실제로 정수를 증가 및 감소시키는 것보다 더 많은 것이 있다는 것이다.

 

  • 첫째, 증가 및 감소를 실행하기 위한 몇 가지 수준의 간접 참조가 존재한다.

  • 그러나 더 중요한 것은 힙 할당과 마찬가지로 여러 스레드가 힙 인스턴스에 참조를 동시에 추가하거나 제거할 수 있기 때문에 고려해야 할 thread safety가 있다는 것이다. 실제로 참조 카운트를 원자적으로 증가 및 감소 시켜야 한다.

  • 그리고 참조 카운팅 작업의 빈도로 인해 이 비용이 증가될 수 있다.

 

  • 이제 Point 클래스 프로그램으로 돌아가서 Swift가 실제로 우리를 대신하여 무엇을 하는지 살펴보자.

 

  • 여기에는 의사 코드가 있다.

  • Point가 refCount라는 추가 프로퍼티를 얻었음을 알 수 있다.

  • 그리고 우리는 Swift가 retain 위한 몇 번의 호출과 release를 위한 몇 개의 호출을 추가한 것을 볼 수 있다.

  • retain은 우리의 참조 카운트를 원자적으로 증가시킬 것이고 release는 우리의 참조 카운트를 원자적으로 감소시킬 것이다.

  • 이런 식으로 Swift는 힙의 Point에 얼마나 많은 참조가 살아 있는지 추적할 수 있다.

 

  • 이것을 빠르게 추적해보면, 힙에 Point를 구성한 후 해당 Point에 대한 하나의 살아있는 참조가 있기 때문에 1의 참조 카운트로 초기화 된다는 것을 알 수 있다.

 

  • 프로그램을 진행하고 point1point2에 할당하면 이제 두 개의 참조가 있으므로 Swift는 Point인스턴스의 참조 횟수를 원자적으로 증가 시키는 호출코드를 추가한다.

 

  • 계속 실행하면서 point1 사용이 끝나면 Swift는 참조 횟수를 원자적으로 감소시키는 호출 을 추가한다. point1은 더 이상 실제로 살아있는 참조가 아니기 때문.

 

  • 마찬가지로 point2 사용이 끝나면 Swift는 참조 카운트의 원자적 감소를 추가한다.

 

  • 이 시점에서 더 이상 Point인스턴스를 사용하는 참조가 없으므로 Swift는 힙을 lock하고 메모리 블록을 반환하는 것이 안전하다는 것을 알게 된다.

 

 

구조체는 어떨까?

  • 그렇다면 구조체는 어떨까?
  • 구조체와 관련된 참조 카운팅이 있을까?

 

  • 우리는 우리의 Point 구조체를 구성할 때 관련된 힙 할당이 없었다.

 

  • 복사할 때에도 관련된 힙 할당이 없었다.
  • 이것에 관련된 참조는 없었다.
  • 따라서 Point 구조체에 대한 참조 카운팅 오버헤드가 없다.

 

더 복잡한 구조체는 어떨까?

  • 하지만 더 복잡한 구조체는 어떨까?

  • 여기 String 타입의 text와 UIFont 타입의 font가 포함된 Label 구조체가 있다.

  • 앞에서 들은 것처럼 String은 실제로 해당 문자의 내용을 힙에 저장한다.

    • 따라서 참조 카운트가 필요하다.
  • 그리고 UIFont도 클래스이다.

    • 역시나 참조 카운트가 필요하다.

 

  • 메모리 표현을 보면 Label에 두 개의 참조가 있다.

 

  • 그리고 복사본을 만들 때 실제로 두 개의 참조를 더 추가한다. 하나는 text 저장소에, 다른 하나는 font에 추가한다.

 

  • Swift가 이러한 힙 할당을 추적하는 방법은 retain 및 release 호출을 추가하는 것이다.
  • 따라서 여기에서 Label이 실제로 클래스가 가질 수 있는 참조 카운팅 오버헤드의 두 배를 발생 시킬 것임을 알 수 있다.

 

  • 요약하자면 클래스가 힙에 할당되기 때문에 Swift는 해당 힙 할당의 수명을 관리해야 한다.
  • 그리고 참조 카운팅으로 그렇게 한다.
  • 참조 카운팅 작업이 상대적으로 빈번하고 참조 카운팅의 원자성 때문에 이것은 사소하지 않다.

 

  • 이것은 구조체를 사용하라는 것을 의미한다.

 

  • 그러나 구조체에 참조가 포함된 경우 참조 계산 오버헤드도 지불하게 된다.
  • 사실, 구조체는 포함하고 있는 참조 수에 비례 하여 참조 계산 오버헤드를 지불하게 될 것이다.

 

  • 따라서 참조가 두 개 이상인 경우 클래스보다 참조 계산 오버헤드가 더 많이 들게 된다.

 

(이론상의) 메시지 앱 예제에서 이것을 어떻게 연결하여 적용하는지 알아보자

  • 사용자들은 단순히 문자 메시지를 보내는 데 만족하지 않는다.

    • 그들은 또한 서로에게 이미지와 같은 첨부 파일을 보내고 싶어할 것이다.
    • 그래서 앱에 모델 object인 구조체 Attatchment가 있다.
  • 첨부 파일에 대한 디스크 내 데이터의 경로를 저장하는 fileURL 프로퍼티가 있다.

  • uuid는 무작위로 생성된 고유 식별자이므로 클라이언트와 서버 및 다른 클라이언트 장치에서 이 첨부 파일을 인식할 수 있다.

  • JPG, PNG 또는 GIF와 같이 첨부 파일이 나타내는 데이터 타입을 저장하는 mimeType이 있다.

  • 아마도 이 예제에서 중요하지 않은 유일한 코드는 실패할 수 있는 생성자일 것이다. 이 이니셜라이저는 모든 mimeType을 지원하지 않기 때문에 mimeType이 이 앱에 대해 지원되는 mimeType 중 하나인지 확인한다.

    • 지원되지 않는 경우 이 작업을 중단한다.
    • 그렇지 않으면 fileURL, uuid 및 mimeType을 초기화한다.

 

  • 우리는 많은 참조 카운팅 오버헤드를 발견했다.
  • 이 구조체의 메모리 표현을 실제로 살펴보면 각 구조체가 힙 할당에 대한 참조가 내포되어 있기 때문에 이를 전달할 때 3가지 프로퍼티 모두 참조 카운팅 오버헤드를 발생 시킨다.

 

  • 우리는 이를 개선시킬 수 있다.

  • 첫째, 앞에서 본 것처럼 uuid는 정말 잘 정의된 개념이다.

    • 이는 128비트 무작위로 생성된 식별자이다.
  • 그리고 우리는 이 uuid 필드에 아무거나 넣도록 허용하고 싶지 않다.

  • 올해 Foundation 은 uuid에 대해 새로운 value 타입을 추가했다. 이는 해당 128비트를 구조체에 직접 저장하기 때문에 정말 좋다.

  • 이것이 할 일은 문자열이었던 uuid 필드에 대해 지불하는 참조 카운팅 오버헤드를 제거하는 것이다.

  • 그리고 여기에 아무거나 넣을 수 없기 때문에 훨씬 더 엄격한 안전이 확보 되었다.

    • uuid만 넣을 수 있다.

 

  • mimeType을 살펴보고 이 isMimeType 검사를 구현한 방법을 살펴보자.
  • 현재 JPG, PNG, GIF의 mimeTypes 세트만 지원하고 있다. (closed set)
  • 그리고 Swift는 고정된 집합을 표현 하기 위한 훌륭한 추상화 메커니즘을 가지고 있다.
    • 바로 열거형.

 

  • 이렇게 switch 문을 실패 가능한 이니셜라이저 안에 넣고 이러한 mimeTypes을 적절한 경우에 내 열거형의 case로 적절하게 매핑할 것이다.
  • 이제 이 mimeType 열거형으로 더 많은 타입 안전성을 갖게 되었고 힙에 간접적으로 저장할 필요가 없기 때문에 성능도 향상되었다.

 

  • 실제로 Swift는 raw String 값으로 뒷받침되는 enum을 사용함으로써 이렇게 정확한 코드를 작성하기 위한 정말 간결하고 편리한 방법을 가지고 있다.
  • 따라서 이것은 훨씬 더 강력하고 동일한 성능 특성을 가지며 작성하는 것이 훨씬 더 편리하다는 점을 제외하고는 사실상 완전히 동일한 코드이다.

 

  • 이제 Attatchment 구조체를 보면 훨씬 더 타입이 안전하다.
  • 강력한 타입의 uuid 및 mimeType 필드가 있으며 uuid 및 mimeType이 참조 카운트되거나 힙 할당될 필요가 없기 때문에 참조 카운트 오버헤드를 지불 하지 않는다.

 

Method Dispatch

  • 성능의 마지막 차원인 메서드 디스패치로 넘어가보자.
  • 런타임에 메서드를 호출할 때 Swift는 올바른 구현(implementation)을 실행해야 한다.

 

Static Dispatch

  • 컴파일 타임에 실행할 구현(implementation)을 결정할 수 있는 경우 이를 정적 디스패치라고 한다.

    • 이 경우 런타임에 올바른 구현으로 바로 이동할 수 있다.
  • 그리고 이것은 컴파일러가 실제로 어떤 구현이 실행될 것인지에 대한 가시성을 가질 수 있기 때문에 정말 멋지다.

  • 인라인과 같은 것을 포함하여 이 코드를 매우 적극적으로 최적화 할 수 있다.

  • 이것은 동적 디스패치와 대조된다.

 

Dynamic Dispatch

  • 동적 디스패치는 어떤 구현으로 이동할지 직접 컴파일 시간에 결정할 수 없다.

  • 따라서 런타임에 실제로 구현을 찾은 다음 바로 실행한다.

  • 동적 디스패치 자체는 정적 디스패치보다 비용이 많이 들지는 않는다.

  • 여기에는 한 가지 수준의 간접 참조만 있을 뿐이다.

  • 참조 카운팅 및 힙 할당과 같은 스레드 동기화 오버헤드는 없다.

  • 컴파일러는 정적 디스패치에 대해 모든 멋진 최적화(inlining과 optimization)를 수행할 수 있지만 동적 디스패치는 컴파일러의 가시성을 차단 하므로 최적화를 할 수 없다.

 

inlining?

  • 그래서 인라인을 언급했다.
  • 인라이닝이란?

 

  • 자, 친숙한 구조체 Point로 돌아가 보자.
  • x와 y가 있고 draw 메서드가 있다.
  • drawAPoint 메서드도 추가했다.
  • drawAPoint 메서드는 Point를 가져와서 draw를 호출한다.

 

  • 그런 다음 프로그램의 본문에서 (0, 0)에 Point를 구성하고 그 point를 drawAPoint에 전달한다.
  • drawAPoint 함수 와 point.draw 메서드는 모두 정적으로 dispatch된다.

 

  • 이것이 의미하는 바는 컴파일러가 어떤 구현이 실행될 지 정확히 알고 있으므로 실제로 drawAPoint 디스패치를 가져 오고 그것을 drawAPoint의 구현으로 대체 한다는 것이다.

 

  • 그런 다음 point.draw 메서드를 사용한다. 이는 정적 디스패치이므로 point.draw의 실제 구현으로 대체할 수 있다.

 

  • 런타임에 이 코드를 실행 하면 그냥 Point를 만든 다음에 함수 구현을 실행하여 완료될 것이다.

  • 두 가지 정적 디스패치 오버헤드와 호출 스택 관련 '설정 및 해제'의 두 가지 오버헤드가 필요하지 않았다.

  • 이것은 정적 디스패치가 왜, 그리고 어떻게 동적 디스패치보다 빠른지 그 이유에 대해 설명한다.

  • 단일 동적 디스패치와 비교하여 단일 정적 디스패치가 큰 차이점을 가지지는 않는다. 하지만 전체 정적 디스패치 체인은 컴파일러가 해당 전체 체인을 통해 가시성을 갖게 된다 .

  • 반면 동적 디스패치 체인은 더 높은 레벨의 모든 추론단계에서 차단 될 것이다.

  • 따라서 컴파일러는 호출 스택 오버헤드가 없는 단일 구현으로 정적 메서드 디스패치 체인을 축소할 수 있다

 

왜 우리는 이 동적 디스패치를 가지고 있는가?

  • 그렇다면 왜 우리는 이 동적 디스패치를 가지고 있을까?

 

  • 그 이유 중 하나는 이것이 다형성과 같은 정말 강력한 것을 가능하게 하기 때문이다.
  • Drawable 추상 super클래스가 있는 기존의 객체 지향 프로그램을 보면 고유한 커스텀 구현으로 draw를 재정의 하는 Point 하위 클래스와 Line 하위 클래스를 정의할 수 있다.

 

  • 그리고 나서 Drawable 배열을 다형성으로 생성 하고 있다.
    • 이 배열에는 PointLine이 포함될 수 있다.
    • 그리고 각각에 대해 draw를 호출할 수 있다.
  • 어떻게 작동할까?

 

  • PointLine이 모두 클래스이기 때문에 이러한 것들의 배열을 만들 수 있고 배열에 참조로 저장하기 때문에 크기가 모두 동일하다.
  • 그리고 각각에 대해 draw를 호출할 것이다.

 

  • 여기서 컴파일러가 컴파일 타임 에 실행할 올바른 구현을 결정할 수 없는 이유를 이해할 수 있고 직관을 가질 수 있다.
  • 이 d.draw는 Point가 될 수도 있고, Line이 될 수도 있기 때문이다.
  • 이것들은 서로 다른 코드 경로를 가진다.
  • 그렇다면 어떤 것을 호출할지 어떻게 결정할까?

 

  • 컴파일러는 해당 클래스의 타입 정보에 대한 포인터를 클래스에 추가 하고, 그것은 정적 메모리(static?)에 저장된다.

    Line.Type 메타데이터

  • 그리고 draw를 호출할 때 컴파일러가 실제로 우리를 대신하여 생성 하는 것은, 실행할 올바른 구현에 대한 포인터를 가지는 타입 및 정적 메모리에 있는 virtual method table이라는 타입을 조회 하는 것이다.

    → 어떤 구현사항을 실행해야 할지 Line.Type이 알고 있으며 이를 컴파일러가 조회

  • 따라서 이 d.draw를 컴파일러가 우리를 대신하여 수행하는 작업으로 변경하면 실제로 실행할 올바른 draw 구현을 찾기 위해 virtual method table을 검색하는 것을 볼 수 있다.

  • 그런 다음 실제 인스턴스를 암시적 self 매개변수로 전달한다.

 

  • 자, 우리는 여기서 무엇을 보았는가?

  • 클래스는 기본적으로 메서드를 동적으로 dispatch한다.

  • 이것은 그 자체로 큰 차이를 만들지 않지만 메서드 체인 및 기타 사항과 관련하여 인라인과 같은 최적화를 방지 할 수 있고 합산될 수 있다.

  • 그러나 모든 클래스에 동적 디스패치가 필요한 것은 아니다.

 

  • 클래스를 하위 클래스로 만들 의도가 없다면 final로 표시하여 팀원과 미래의 나에게 그것이 의도한 것임을 전달할 수 있다.
  • 컴파일러는 이것을 선택하고 해당 메서드를 정적으로 디스패치 할 것이다.
  • 또한 컴파일러가 앱에서 클래스를 서브클래싱하지 않을 것임을 추론하고 증명할 수 있다면 기회에 따라 이러한 동적 디스패치를 사용자를 대신해 정적 디스패치로 전환한다.

 

이제 어떻게 해야하는가

  • 그렇다면 우리는 이제 어떻게 해야하는가?

  • 이 강연의 전반부에서 자기 자신에게 던져야 하는 질문이 있었다.

    • Swift 코드를 읽고 작성할 때마다 "이 인스턴스가 스택에 할당될 것입니까 아니면 힙에 할당될 것입니까?"를 생각해야 한다.
    • 이 인스턴스를 전달할 때 오버헤드가 포함된 참조가 얼마나 발생됩니까?
    • 이 인스턴스에서 메서드를 호출하면 정적 또는 동적으로 디스패치됩니까?
  • 필요하지 않은 dynamism(역동성)을 위해 비용을 지불한다면 성능이 저하될 것이다.

  • 그리고 Swift를 처음 사용하거나 Objective C 에서 Swift 로 이식된 코드 기반에서 작업 하는 경우 현재보다 구조체를 더 많이 활용할 수 있다.

  • 예제에서 본 것처럼 문자열 대신 구조체를 사용했던 것들과 같이.

  • 그러나 한 가지 질문은 "구조체를 사용하여 다형성 코드를 작성 하는 방법은 무엇입니까?"이다.

  • 우리는 아직 그것을 보지 못했다.

  • 답은 프로토콜 지향 프로그래밍이다.

 

Protocol Types

  • 프로토콜 타입을 알아보면서 프로토콜 타입 및 generic 코드를 어떻게 구현해야 하는지 알아보자
  • 프로토콜 타입의 변수가 저장되고 복사되는 방식과 메서드 디스패치가 작동하는 방식을 살펴보도록 하자.
  • 이번에는 프로토콜 타입을 사용하여 구현한 앱으로 돌아가 보자.

 

  • Drawable 추상 base 클래스 대신 이제 draw 메서드를 선언한 Drawable 프로토콜이 있다.
  • 그리고 프로토콜을 준수하는 value type struct Point 및 struct Line이 있다.

 

  • 프로토콜을 준수 하는 SharedLine 클래스를 가질 수도 있다.
  • 그러나 클래스가 가지는 reference semantic 체계가 의도하지 않은 공유를 가져다주므로 그렇게 하지 않기로 결정했다.

 

  • 우리 프로그램은 여전히 다형성을 가지고 있다.
  • Drawable 프로토콜 타입 배열에 Point 타입과 Line 타입의 값을 모두 저장할 수 있다.
  • 그러나 이전과 비교하면 한 가지가 다르다.

 

  • value type struct Line 과 struct Point는 이전에 본 메커니즘인 V-Table 디스패치를 수행하는 데 필요한 공통 상속 관계를 공유하지 않는다.

  • 그렇다면 Swift는 어떻게 올바른 메소드로 디스패치할까?

    • 현재의 경우 배열을 통해 코드가 진행되는 동안 어떻게 디스패치할까?

 

  • 이 질문에 대한 답은 Protocol Witness Table 이라는 테이블 기반 메커니즘이다.
  • 앱에서 프로토콜을 구현하는 타입 하나당 이러한 테이블 하나를 가진다.

 

  • 그리고 해당 테이블의 항목은 타입 구현에 연결된다.
  • 이제 우리는 해당 메서드를 찾는 방법을 알게 되었다.

 

  • 그러나 여전히 "배열의 요소에서 테이블로 이동하는 방법은 무엇일까?"라는 질문이 남는다.
  • 그리고 또 다른 질문이 생긴다.

 

  • 어떻게 값들을 일관적으로 저장할까?

  • 일단 LinePoint가 value type이라는 것을 명심하자.

  • Line은 4 word가 필요하다.

  • Point는 2 word가 필요하다.

    • 그들은 같은 크기를 가지고 있지 않다.
  • 그러나 우리 배열은 배열의 고정 오프셋(고정된 공간)에 균일하게 요소를 저장하려고 한다.

  • 어떻게 작동할까?

 

  • 이 질문에 대한 대답은 Swift가 Existential Container 라는 특별한 스토리지 레이아웃을 사용한다는 것이다.
  • 여기에는 무엇이 있는가?

 

  • 해당 Existential Container의 처음 세 word 는 valueBuffer용으로 예약되어 있다.

 

  • 두 word만 필요한 Point와 같은 작은 타입은 이 valueBuffer에 맞는다.
  • 잠깐만요! Line은?
    • 네 word가 필요하잖아요! 그걸 어디에 둬?

 

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

  • 이제 LinePoint 사이에 차이가 있음을 확인했다.

  • 따라서 어떻게든 Existential Container는 이 차이를 관리해야 한다.

  • 어떻게 그렇게 하나요?

  • 이에 대한 대답은, 다시 테이블 기반 메커니즘이다.

 

  • 이 경우 우리는 그것을 Value Witness Table이라고 부른다.
  • Value Witness Table은 value 수명을 관리하며 타입별로 이러한 테이블 중 하나를 가진다.
  • 이제 이 테이블이 어떻게 작동하는지 보기 위해 지역 변수의 lifetime을 살펴보자.

 

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

 

  • 이 함수는 이제 (이 경우에 Line Value Witness Table이 있으므로) 힙에 메모리 를 할당 하고 Existential Container의 valueBuffer 내부 에 해당 메모리에 대한 포인터를 저장할 것이다.
  • 다음으로, Swift는 로컬 변수를 초기화하는 할당 소스(source of assignment)의 값을 Existential Container로 복사해야 한다 .

 

  • 여기 Line이 있으므로 Value Witness Table의 복사 항목은 올바른 작업을 수행하고 이를 힙에 할당된 valueBuffer에 복사한다.
  • 프로그램이 계속되고 지역 변수의 수명이 다했을 때를 살펴보자.

 

  • Swift 는 Value Witness Table에서 파괴 항목을 호출한다. 그러면 타입에 포함되어 있었을 참조 카운트가 감소한다.
  • Line에는 아무것도 없으므로 이제 필요하지 않다.

 

  • 그리고 마지막으로 Swift는 해당 테이블에서 할당 해제 함수를 호출한다.
  • 다시 말하지만, Line에 대한 Value Witness Table이 있으므로 이것은 값에 대해 힙에 할당된 메모리를 할당 해제할 것이다.
  • 이렇게 우리는 Swift가 다른 종류의 값을 일률적으로 다룰 수 있는 방법에 대한 메커니즘 을 보았다 .
  • 그러나 여전히, 어떻게 해당 테이블에 도달해야 하는지는 의문이다.
  • 답은 뻔하다.

 

  • Value Witness Table의 다음 항목은 참조이다.
  • Existential Container에는 Value Witness Table에 대한 참조 가 있다.
  • 마지막으로 Protocol Witness Table에 도달하는 방법은 무엇일까?

 

  • 이 역시 Existential Container에서 참조된다.

  • 우리는 Swift가 프로토콜 타입의 값을 관리하는 방법의 메커니즘 을 보았다.

  • Existential Container가 실제로 어떻게 작동하는지 예제를 통해 살펴보자.

 

Existential Container는 어떻게 작동하는가

  • 이 예제에는 프로토콜 타입 매개변수 local을 받고 draw 메서드를 실행하는 함수가 있다.
  • 그런 다음 우리 프로그램은 Drawable 프로토콜 타입의 지역변수를 생성하고 Point로 초기화한다.
  • 그리고 이 지역변수 를 drawACopy 함수 호출의 인자로 전달한다.

 

  • Swift 컴파일러가 생성하는 코드를 설명하기 위해 아래쪽에 의사코드 표기법으로 Swift를 사용하였다.
  • Existential Container는 valueBuffer에 대한 세 word 저장소와 value witness 및 protocol witness table에 대한 참조를 가진 구조체이다.

 

  • drawACopy 함수 호출이 실행되면 인자를 받아 함수에 전달한다.
  • generated code에서 Swift가 해당 함수의 인자로 Existential Container를 전달하는 것을 볼 수 있다.

 

  • 함수가 시작하면 해당 매개변수에 대한 지역 변수를 만들고 인자를 할당한다.

 

  • 이후 generated code에서 Swift는 스택에 Existential Container를 할당한다.

 

  • 다음으로 인자로 들어온 existential container에서 value witness tableprotocol witness table을 읽고 local existential container필드를 초기화한다.

 

  • 다음으로, 필요하다면 버퍼를 할당하는 value witness 함수를 호출하고 값을 복사한다.
  • 이 예에서는 동적 힙 할당이 필요하지 않다.
  • 이 함수(allocateBufferAndCopyValue)는 인자의 값을 local existential container의 valueBuffer로 복사한다.

 

  • 만약 Line을 전달받았다면 이 함수는 버퍼를 할당하고 거기에 값을 복사했을 것이다.

 

  • 다음으로 draw 메서드가 실행되고 Swift는 existential container의 필드에서 protocol witness table을 찾은 뒤 해당 테이블의 고정 오프셋에서 draw 메서드를 찾은 다음 구현부분으로 이동한다.

 

  • 허나 여기에는 또다른 value witness가 존재한다. 바로 projectBuffer
  • 이게 왜 있을까?
  • pwt.draw 메소드는 입력으로 값 주소를 기대한다 .

 

  • 이 주소값은 인라인 버퍼에 맞는 작은 값인 경우 existential container의 시작위치이고 인라인 valueBuffer에 맞지 않는 큰값이 있는 경우 힙에 있는 주소가 시작위치이다.
  • 따라서 이 value witness 함수는 타입에 따른 이러한 차이를 추상화하였다.

 

  • draw 메서드가 실행되고 완료되면 이제 함수의 끝에 도달한다. 즉 ,매개 변수에 대해 생성된 로컬 변수가 범위를 벗어나게 된다.

 

  • Swift는 이제 값을 파괴하기 위해 value witness 함수를 호출한다. 만약 값에 참조가 있는 경우 참조 카운트가 감소할 것이고 버퍼가 할당되어 있었다면 버퍼 할당이 해제된다.

 

  • 함수 실행이 완료되고 스택이 제거되면서 스택에 생성됐던 local existential container가 제거된다 .

  • 이 작업에서 주목하고자 하는 한 가지는 struct Linestruct Point와 같은 value 타입을 프로토콜과 결합하여 동적 동작, 동적 다형성을 얻을 수 있다는 것이다.

  • Drawable 프로토콜 타입의 배열에 LinePoint를 저장할 수 있었다.

  • 이 역동성이 필요하다면 이것은 꽤 괜찮은 선택이다. 이전에 본 예제의, 클래스를 사용하는 것과 비교해봤을 때 클래스는 V-Table을 통해야 하며 참조 카운팅 추가 오버헤드가 있었기 때문이다.

  • 여기까지 우리는 지역 변수가 복사되는 방식과 프로토콜 타입 value에 대해 메서드 디스패치가 작동하는 방식을 살펴보았다.

 

Protocol Type Stored Properties

  • 저장 프로퍼티를 살펴보자.
  • 이 예에서는 Drawable 프로토콜 타입의 first와 second 두 저장 프로퍼티를 가진 Pair라는 구조체가 있다.
  • Swift는 두 개의 저장된 프로퍼티를 어떻게 저장할까?

 

  • 둘러싸진 구조체 인라인으로 되어있을 것이다.
  • Pair를 할당할 때를 보면 Swift는 둘러쌀 구조체 인라인에 해당 쌍을 저장하는 데 필요한 두 개의 existential container를 저장할 것이다.

 

  • 그런 다음 LinePointpair를 초기화하고 이전에 보았듯이 Line에 대해 힙에 버퍼를 할당한다.
  • Point는 인라인 valueBuffer에 맞고 existential container 인라인에 저장할 수 있다.
  • 이제 이 표현을 사용하면 나중에 프로그램에서 다른 타입의 값을 저장할 수 있다.

 

  • 프로그램이 두 번째 요소에 Line을 저장하려 한다고 해보자.
  • 이것은 작동하면서 이제 두 개의 힙 할당을 만들어낸다.
  • 힙 할당 비용을 설명하기 위해 다른 프로그램을 살펴보도록 하겠다.

 

  • 우리는 다시 Line을 만들고 Pair를 만든 뒤 이 PairLine으로 초기화하였다.

 

  • 이에 따라 두 개의 힙 할당이 생긴다.

CoW에 따라 existential conatiner - value buffer에서 원본값(aLine)을 가리키는 형태로 생기는건 아닌건가?

 

  • 그런 다음 해당 pair의 복사본을 다시 생성한다. 스택내의 두 existential container와 두개의 힙 할당이 생긴다.

  • 이전에 힙 할당은 비싸다고 말했었다.

  • 4개의 힙 할당?

  • 이것에 대해 우리가 할 수 있는 것이 있을까?

 

  • 일단 existential container는 세 word를 위한 자리 가 있고 참조는 기본적으로 한 word이기 때문에 참조가 이 세 word에 들어맞는다는 것을 기억해야한다.
  • 만약 Line을 클래스로 구현했다면 클래스는 reference semantic 체계이므로, 참조에 의해 저장되었을 것이고 이 참조는 valueBuffer에 맞았을 것이다.

 

  • 이에따라 Pair의 첫번째 참조를 두번째 필드로 복사할 때 참조만 복사되고 우리가 지불했을 유일한 것은 추가 참조 카운트 증가였을 것이다.

 

  • 허나 reference semantic 체계는 의도하지 않은 상태 공유를 일으킨다고 우리는 들었었다.

  • Pair의 두 번째 필드를 통해 x1에 특정 값을 저장한다면 첫 번째 필드에서 해당 변경 사항을 관찰할 수 있다.

  • 그리고 그것은 우리가 원하는 것이 아니다.

  • 우리는 value semantic을 원한다.

  • 이에 대해 무엇을 할 수 있을까?

  • 이 문제를 해결할 수 있는 Copy on Write라는 기술이 있다.

 

  • 클래스에 쓰기작업을 하기 전에 참조 횟수를 확인한다.

  • 우리는 동일한 인스턴스에 대해 하나 이상의 참조가 있으면 해당 참조 카운트는 1보다 클 것이라고 알고 있다.

  • 따라서 이 경우 인스턴스에 쓰기 전에 인스턴스를 복사한 다음 해당 복사본에 쓴다.

  • 이렇게 하면 상태가 분리된다.

 

Copy on Write

  • 이제 Line을 위해 이것을 어떻게 할 수 있는지 살펴보자.

  • Line 내부에 Storage를 직접 구현하는 대신 Line 구조체의 모든 필드를 포함하는 LineStorage라는 클래스를 만든다.

  • 그런 다음 Line 구조체가 이 Storage를 참조한다.

  • 그리고 값을 읽고 싶을 때마다 단지 그 저장소 안의 값을 읽는다.

 

  • 그러나 값을 수정하거나 변경 하려면 먼저 참조 횟수를 확인한다.

  • 1보다 큰가?

    • 이것은 isUniquelyReferenced 호출이 달성한다.
    • 이것이 하는 유일한 일은 참조 카운트를 확인하는 것이다.
  • 1보다 크거나 같은가?

    • 참조 카운트가 1 보다 크면 LineStorage의 복사본을 만들고 그것을 변경한다.
  • 우리는 구조체와 클래스를 결합하여 Copy on Write를 사용하는 간접 저장소를 얻는 방법을 알아보았다 .

  • 이번에는 간접 저장을 사용할 때 어떤 일이 발생하는지 보기 위해 예제로 돌아가 보자.

 

  • 여기서 다시 Line을 생성한다.
  • 이것은 힙에 LineStorage 객체를 생성할 것이다.
  • 그런 다음 해당 Line을 사용하여 Pair를 초기화한다.

 

  • 이번에는 LineStorage에 대한 참조만 복사된다.

 

  • 여기서 Line을 다시 복사하려고 하면 참조만 복사 되고 참조 카운트가 증가하게 된다.
  • 이것은 힙 할당보다 훨씬 저렴하다.
    • 좋은 trade off가 된다.

 

  • 우리는 프로토콜 타입의 변수가 복사 및 저장되는 방식과 메서드 디스패치가 작동하는 방식을 살펴보았다.
  • 이것이 성능에 어떤 의미가 있는지 살펴보자.

 

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

  • 구조체에 참조가 포함되어 있지 않으며 참조 카운팅도 없다.

    • 따라서 이것은 정말 빠른 코드이다.
  • 그러나 value witness 및 protocol witness table을 통한 간접방식이므로 동적 디스패치의 모든 기능을 얻을 수 있으므로 동적으로 다형성 동작이 가능하다.

  • 이것을 큰 값을 가진 것과 비교해보자.

 

  • 값이 크면 프로토콜 타입의 변수를 초기화하거나 할당할 때마다 힙 할당이 발생한다.
  • 큰 값 구조체에 참조가 포함된 경우 잠재적인 참조 카운팅도 발생한다.

 

  • 그러나 Copy on Write와 함께 간접 저장소를 사용함으로써 값비싼 힙 할당을 대신 사용할 수 있는 기술을 보여주었다.

    • 더 저렴한 참조 카운팅을 이용.
  • 이것은 클래스를 사용하는 것과 유리하게 비교된다.

  • 클래스에는 참조 카운팅도 발생한다.

    • 그리고 초기화 시 할당된다.

 

Summary

  • 요약하자면 프로토콜 타입은 동적 형태의 다형성을 제공한다.
  • 우리는 프로토콜과 함께 값 타입을 사용할 수 있으며 프로토콜 타입 배열 내부에 LinePoint를 저장할 수 있었다.
  • 이것은 protocol 및 value witness tableexistential container를 사용하여 달성된다.
  • 큰 값을 복사하면 힙 할당이 발생한다.
  • 그러나 간접 저장 및 Copy on Write를 사용해 구조체를 구현함으로써 이 문제를 해결 하는 방법을 보여주었다.

 

  • 앱으로 돌아가서 다시 살펴보자.
  • 함수에서 Drawable 프로토콜 타입의 매개변수를 복사해야 했다.

 

  • 그러나 우리는 그것을 항상 구체적인 타입으로써 사용한다.
  • 여기서는 Line에서 사용했었다.

 

  • 나중에 프로그램에서는 Point에 대해 쓸 수도 있다.

  • 여기에 generic 코드를 사용할 수 있을까?

  • 가능하다!

  • 한번 살펴보도록 하자.

 

Generic Code

  • 이 강연의 마지막 부분에서 제네릭 타입의 변수가 저장되고 복사되는 방식과 메서드 디스패치가 이러한 변수와 함께 작동하는 방식을 살펴볼 것이다.

 

  • 따라서 이번에는 generic 코드를 사용하여 구현된 애플리케이션으로 돌아가보자.
  • 이제 drawACopy 메서드는 Drawable이라는 generic 매개변수 제약 조건을 취하고 있으며 나머지 부분은 이전과 동일하다.
  • 이것은 프로토콜 타입과 비교할 때 무엇이 다를까?

 

  • generic 코드는 매개변수 다형성이라고도 하는, 보다 정적 형태의 다형성을 지원한다.

    • 호출 컨텍스트당 하나의 타입.
  • 이게 무슨말인가?

  • 이 예를 살펴보자.

  • Drawable 제약 조건을 가진 T라는 generic 매개변수를 취하는 함수 foo가 있다. 그리고 이 함수는 해당 매개변수를 함수 bar에 전달한다.

  • 이 bar 함수는 다시 generic 매개변수 T를 사용한다.

  • 그런 다음 프로그램은 Point를 만들고 이 point를 foo 함수에 전달한다.

 

  • 이 함수가 실행될 때, Swift는 제네릭 타입 T를 이 호출 측에서 사용되는 타입(이 경우에는 Point)에 바인딩한다.
  • foo 함수가 이 바인딩으로 실행되고 bar의 함수 호출에 도달하면 이 로컬 변수는 방금 찾은 타입, 즉 Point를 갖는다.

 

  • 따라서 다시 이 호출 컨텍스트의 generic 매개변수 T는 Point타입으로 바인딩된다.

  • 여기서 볼 수 있듯이 타입은 매개변수를 따라 호출 체인 아래로 대체되어 나간다.

  • 이것이 보다 정적인 형태의 다형성 또는 매개변수적 다형성을 의미한다.

  • 이제 Swift가 내부적으로 이것을 구현하는 방법을 살펴보자.

 

  • 다시 drawACopy 함수로 돌아가보자.
  • 이 예에서는 Point를 전달한다.

 

  • 프로토콜 타입을 사용했을 때와 마찬가지로 하나의 공유 구현이 있다.
  • 그리고 이 공유 구현은 이전에 프로토콜 타입에 대해 했던 코드와 매우 유사하게 보일 것이다.

 

  • 이것은 protocolvalue witness table을 사용하여 일반적으로 해당 함수 내부의 작업을 수행한다.

 

  • 그러나 호출 컨텍스트당 하나의 타입만 있기 때문에 Swift는 여기서 existential container를 사용하지 않는다.

 

  • 대신, 이 호출에서 사용된 Point 타입의 value witness tableprotocol witness table을 함수에 대한 추가 인자로 전달할 수 있다.
  • 따라서 이 경우 PointLine에 대한 value witness table이 전달 되었음을 알 수 있다.

 

  • 그런 다음 해당 함수를 실행하는 동안, 매개변수에 대한 로컬 변수를 생성할 때 Swift는 value witness table을 사용하여 잠재적으로 힙에 필요한 버퍼를 할당하고 할당 소스(source of assignment)에서 목적지로 복사를 실행한다.

 

  • 그리고 로컬 매개변수에 대해 draw 메서드를 실행할 때와 유사하게, 전달된 protocol witness table을 사용하고 테이블 내의 고정 오프셋의 draw 메서드를 찾아 구현으로 이동한다.

  • 여기에는 existential container가 없다고 방금 말했다.

  • 그렇다면 Swift는 이 지역 매개변수를 위해 생성된 지역 변수에 대해 필요한 메모리를 어떻게 할당할까?

 

  • 바로, 스택에 valueBuffer를 할당한다.
  • 다시 말하지만, 이 valueBuffer는 3 word이다.

 

  • Point와 같은 작은 값은 valueBuffer에 맞는다.

 

  • Line과 같은 큰 값은 다시 힙에 저장되고 local existential container 내부에 해당 메모리에 대한 포인터를 저장한다.

  • 그리고 이 모든 것은 value witness table 사용을 통해 관리된다.

  • 이제 "여기에서 프로토콜 타입을 사용하지 않았을 수 있었을까" 라는 질문을 던질 수 있다.

  • 이 정적 형태의 다형성은 제네릭의 특수화라고 하는 컴파일러 최적화를 가능하게 한다.

  • 한 번 살펴보자.

 

  • 다시 말하지만, 여기에 generic 매개변수를 사용하는 함수 drawACopy가 있으며 해당 메서드를 호출하면서 함수에 Point를 전달한다.

 

  • 그리고 우리는 정적 다형성을 가지고 있으므로 호출 site에 하나의 타입이 존재한다.

 

  • Swift는 해당 타입을 사용하여 함수의 generic 매개변수를 대체하고 해당 타입에 고유한 해당 함수 버전을 생성한다.

  • 따라서 여기에 Point 타입의 매개변수를 사용하는 Point 함수 drawACopy가 있으며 해당 함수 내부의 코드는 다시 해당 타입에 대해 고유하다.

  • 그리고 Kyle이 보여준 것처럼 이것은 정말 빠른 코드가 될 수 있다.

 

  • Swift는 프로그램의 호출 사이트에서 사용되는 타입별로 버전을 생성한다.

  • 따라서 PointLine에 대해 drawACopy함수를 호출하면 해당 함수의 두 가지 버전으로 맞춤화하고 생성한다.

  • 잠깐! 이것은 코드 크기를 많이 증가시킬 가능성이 있다.

  • 그러나 사용할 수 없는 정적 타이핑 정보는 적극적인 컴파일러 최적화를 가능하게 하기 때문에 Swift는 실제로 잠재적인 방향으로 코드 크기를 줄일 수 있다.

 

  • 예를 들어 Point 메서드 함수의 drawACopy를 인라인한다.

 

  • 그런 다음 코드에 더 많은 컨텍스트가 있으므로 코드를 추가로 최적화한다.
  • 그래서 함수 호출은 기본적으로 이 한 줄로 줄어들 수 있고 Kyle이 우리에게 보여줬듯이 이것은 draw 구현으로 훨씬 더 줄일 수 있다.

 

  • 이제 Point 메서드의 drawACopy가 더 이상 참조되지 않으므로 컴파일러는 이를 제거 하고 Line 예제에 대해 유사한 최적화를 수행한다.

  • 따라서 이 컴파일러 최적화가 반드시 코드 크기를 증가시키는 것은 아니다.

    • 일어날 수있는 것일뿐 반드시 그런 것은 아니다.
  • specialization이 어떻게 작동하는지 살펴 보았지만 한 가지 질문은 "언제 발생합니까?"이다.

  • 아주 작은 예를 살펴보자.

 

When Does Specialization Happen?

  • Point를 정의한 다음 해당 타입의 지역 변수를 생성하였다.

  • point는 이를 Point로 초기화한 다음 해당 Point를 drawACopy함수에 대한 인자로 전달한다.

  • 이제 이 코드를 specialization하기 위해 Swift는 이 호출 site에서 타입을 유추 할 수 있어야 한다.

  • 로컬 변수를 보고, 초기화로 돌아가 Point로 초기화되었는지 확인할 수 있기 때문에 그렇게 할 수 있다.

  • 또한 Swift는 specialization 중에 사용되는 타입과 그 자체로 사용 가능한 generic 함수에 대한 정의가 있어야 한다.

  • 이번에도 마찬가지이다.

  • 모두 하나의 파일에 정의되어 있다.

  • 이것은 전체 모듈 최적화(Whole Module Optimization)가 최적화 기회를 크게 향상시킬 수 있는 곳이다.

  • 왜 그런지 살펴보도록 하자.

 

  • Point 정의를 별도의 파일로 옮겼다고 가정해 보도록 하겠다.
  • 이제 이 두 파일을 별도로 컴파일하면, UsePoint 파일을 컴파일하려고 할 때 컴파일러가 두 파일을 별도로 컴파일했기 때문에 Point 정의를 더 이상 사용할 수 없다.

 

  • 그러나 전체 모듈 최적화를 통해 컴파일러는 두 파일을 하나의 단위로 함께 컴파일하고 Point 파일 정의에 대한 통찰력을 갖게 되며 최적화가 발생할 수 있다.
  • 이렇게 하면 최적화 기회가 크게 향상되므로 이제 Xcode 8부터는 전체 모듈 최적화가 기본적으로 활성화된다.
  • 우리 프로그램으로 돌아가보자.

 

  • 우리 프로그램은 이러한 Drawable 프로토콜 타입 쌍을 갖고 있다.
  • 그리고 우리는 우리가 그것을 어떻게 사용하는지에 대해 알았다.

 

  • Pair를 만들고 싶을 때마다 실제로는 같은 타입의 쌍, 예를 들어 Line 쌍 또는 Point 쌍을 만들기를 원했다.

 

  • 이제 한 쌍의 Line에 대한 storage에는 두 개의 힙 할당이 필요 하다는 것을 기억해라.
  • 이 프로그램을 볼 때 여기에서 제네릭 타입을 사용할 수 있다는 것을 깨달았다.

 

  • 따라서 쌍을 제네릭으로 정의한 다음 해당 제네릭 타입의 first 및 second 프로퍼티가 이 제네릭 타입을 갖도록 하면 컴파일러는 실제로 동일한 타입의 쌍만 생성 하도록 강제할 수 있다.

  • 이렇게 하면 나중에 프로그램에서 한 쌍의 Line 묶음에 Point를 저장할 수 없게 된다.

  • 이것이 우리가 원했던 것이다. 그러나 이것은 성능이 더 좋을까 나쁠까?

  • 한 번 살펴보자.

 

  • 여기 Pair가 있다.

  • 이번에는 저장 프로퍼티가 generic 타입이다.

  • 런타임에 타입을 변경할 수 없다고 말한 것을 기억하자.

  • generated code가 의미하는 바는 Swift가 enclosing 타입의 저장소를 인라인으로 할당할 수 있다는 것이다.

  • 따라서 Line 쌍을 만들 때 Line에 대한 메모리는 실제로 enclosing 쌍의 인라인으로 할당된다.

  • 추가 힙 할당이 필요하지 않다.

 

  • 나중에 다른 타입의 값을 저장프로퍼티에 저장할 수도 없다.
  • 이것은 우리가 원했던 것이다.

 

  • value witnessprotocol witness table을 사용하여 specialized되지 않은 코드가 작동하는 방식과 컴파일러가 generic 함수의 타입별 버전을 생성하는 코드를 specialize할 수 있는 방법을 살폈다.
  • 구조체를 포함하는 특수화된 generic 코드를 먼저 살펴보고 성능을 살펴보자.

 

  • 이 경우, 방금 보았듯이 generated code는 본질적으로 이 함수를 구조체로 작성한 것과 같기 때문에 구조체 타입을 사용하는 것과 동일한 성능 특성을 갖는다.

  • 구조체 타입의 값을 복사할 때 힙 할당이 필요하지 않다.

  • 구조체에 참조가 포함되어 있지 않으면 참조 카운팅이 없다.

  • 또한 컴파일러 최적화를 추가로 가능하게 하고 런타임 실행 시간을 줄이는 정적 메서드 디스패치를 가지고 있다.

 

  • 이를 클래스 타입과 비교해보면 클래스 타입을 사용하면 힙 할당 및 인스턴스 생성, 값 전달을 위한 참조 카운팅 , V-Table을 통한 동적 디스패치같은 클래스와 유사한 특성을 갖게 된다.
  • 이제 작은 값을 포함하는 특수화되지 않은 generic 코드를 살펴보자.

 

  • 스택에 할당된 valueBuffer에 작은 값이 들어맞기 때문에 지역 변수에 힙 할당이 필요하지 않다.
  • 값에 참조가 포함되지 않은 경우 참조 카운팅도 없다.
  • 그러나 witness table을 사용하여 모든 잠재적 호출 site에서 하나의 구현을 공유하게 된다.

 

  • 큰 값과 generic 코드를 사용하는 경우 힙 할당이 발생한다.

  • 그러나 이전에 간접 저장 기술을 해결 방법으로써 사용 하는 것을 보여줬었다.

  • 큰 값에 참조가 포함된 경우 참조 카운팅이 생기고, 동적 디스패치의 힘을 얻는다. 즉, 코드 전체에서 하나의 일반 구현을 공유할 수 있다.

  • 그래서 우리는 오늘 구조체와 클래스의 성능 특성이 어떻게 생겼는지, generic 코드가 어떻게 작동하는지, 프로토콜 타입이 어떻게 작동하는지 보았다.

  • 이것에서 무엇을 얻을 수 있을까?

  • 자, 드디어 마지막 본론으로 가보자.

 

Summary

  • 동적 런타임 타입 요구 사항이 가장 적은, 엔티티에 적합한 추상화를 선택하자.

  • 이렇게 하면 정적 타입 검사가 가능하고 컴파일러는 컴파일 타임에 프로그램이 올바른지 확인할 수 있다.

  • 또한 컴파일러는 코드를 최적화하기 위한 더 많은 정보를 가지고 있으므로 더 빠른 코드를 얻을 수 있다.

  • 따라서 구조체 및 열거형과 같은 값 타입을 사용하여 엔티티를 표현할 수 있다면 value semantic 체계를 얻을 수 있으며 이는 의도하지 않은 상태 공유가 없고 최적화 가능한 코드를 얻을 수 있다.

  • 엔티티에 있어, 또는 객체 지향 프레임워크로 작업을 해야해서 클래스를 사용해야 하는 경우 Kyle은 참조 카운팅 비용을 줄이는 몇 가지 기술을 보여주었었다.

  • 프로그램 일부를 보다 정적인 형태의 다형성을 사용하여 표현할 수 있는 경우 generic 코드를 값 타입과 결합할 수 있으며 매우 빠른 코드를 얻을 수 있으면서도 해당 코드에 대한 구현도 공유할 수 있다.

  • 그리고 Drawable 프로토콜 타입 예제의 배열과 같이 동적 다형성이 필요한 경우 프로토콜 타입을 값 타입과 결합하여, 클래스를 사용하는 것보다 비교적 빠른 코드를 얻을 수 있으면서도 여전히 value semantic을 유지할 수 있다.

  • 그리고 프로토콜 타입 또는 제네릭 타입 내부에서 큰 값을 복사하기 때문에 힙 할당에 문제가 발생하는 경우 해당 기술, 즉 Copy on Write와 간접 저장을 사용하여 이 문제를 해결하는 방법을 보여줬었다.

 

참고하면 좋은 링크

zeddiOS - Understanding Swift Performance 1

zeddiOS - Understanding Swift Performance 2

zeddiOS - Understanding Swift Performance 3

profile
iOS 공부중인 Coden

3개의 댓글

comment-user-thumbnail
2022년 8월 28일

와 이걸 하다니 대박이다

1개의 답글
comment-user-thumbnail
2022년 11월 30일

[비밀 댓글입니다.]

답글 달기