아래 글은 WWDC 2016 - Understanding Swift Performance 영상을 한글로 번역 / 의역한 것입니다. 오역 및 잘못된 내용이 있다면 피드백 부탁드립니다.
캡처된 그림 및 사진은 모두 해당 WWDC의 Presentation Slides (PDF)에서 가져온 것입니다.
이미지에 width 속성을 적용하였으나 반영이 되지 않아 크게 보일 수 있습니다.
이 고급 세션에서는 구조체, 클래스, 프로토콜 및 제네릭이 Swift에서 구현되는 방법을 알아본다. 다양한 성능 차원에서 상대적 비용에 대해 알아보도록 하자. 그리고 이 정보를 적용하여 코드 속도를 높이는 방법을 알아보자.
추상화를 building하고 추상화 메커니즘을 선택할 때 아래의 질문들을 해봐야 한다.
내 인스턴스가 스택
에 할당될까 힙
에 할당될까?
이 인스턴스를 전달할 때 얼마나 많은 참조 카운팅 오버헤드
가 발생할까?
이 인스턴스에서 메서드를 호출하면 정적으로 dispatch
될까 동적으로 dispatch
될까?
빠른 Swift 코드를 작성 하려면 활용하지 않는 dynamism과 runtime에 대한 비용을 지불 하지 않아야 한다.
그리고 우리는 더 나은 성능을 위해 서로 다른 차원 간에 언제 어떻게 거래할 수 있는지 배워야 한다.
Swift 는 사용자를 대신하여 자동으로 메모리를 할당 및 할당 해제한다.
스택은 정말 간단한 데이터 구조이다. 스택의 끝으로 push하고 스택의 끝에서 pop할 수 있다.
스택 끝에서만 추가하거나 제거할 수 있기 때문에, 스택 끝에 포인터를 유지함으로써 스택을 구현하거나 푸시 및 팝을 구현할 수 있다.
그리고 스택 끝에 있는 포인터를 스택 포인터
라고 부른다.
함수를 호출할 때 공간을 만들기 위해 스택 포인터
를 약간 감소시키는 것만 으로 필요한 메모리를 할당할 수 있다.
그리고 함수 실행이 끝나면 스택 포인터
를 이 함수를 호출하기 전의 위치로 다시 증가시켜 메모리 할당을 간단하게 해제할 수 있다.
이 스택 할당은 정말 빠르다.
힙은 스택보다 동적이지만 효율성이 떨어진다 .
힙은 동적 lifetime으로 메모리를 할당할 수 있지만 스택은 이것이 불가능하다.
하지만 이를 구현하기 위해서는 더 고급 자료 구조가 필요하다.
힙에 메모리를 할당 하려면 적절한 크기의 '사용되지 않은 블록'을 찾아야 한다.
그리고 작업이 끝난 뒤 할당을 해제 하려면 해당 메모리를 적절한 위치 에 다시 삽입해야 한다.
힙에는 스택보다 더 많은 것이 관련되어 있다.
여러 스레드가 동시에 힙에 메모리를 할당할 수 있으므로 힙은 lock
또는 기타 동기화 메커니즘
을 사용하여 무결성
을 보호해야 한다. 그리고 이것은 꽤 큰 비용이 된다.
→ 스택은 쓰레드별로 구성되므로 신경쓰지 않아도 된다.
Point
구조체가 있다.point1
을 구성하고 point1
을 point2
에 할당하여 복사본을 만든 뒤 값 5 를 point2.x
에 할당한다 . 그런 다음 point1
을 사용하고 point2
를 사용한다.
point1
인스턴스와 point2
인스턴스를 위해 스택에 공간을 할당했다 .point
는 구조체이기 때문에 x 및 y 속성은 스택의 라인에 저장된다.
point1
을 구성할 때 스택에 이미 할당한 메모리를 초기화하기만 하면 된다.
point1
을 point2
에 할당할 때, point
의 복사본을 만들고 우리가 이미 스택에 할당했던 point2
메모리를 초기화한다. point1
과 point2
는 독립적인 인스턴스이다.
point2.x
에 5라는 값을 할당하면 point2.x
는 5이지만 point1.x
는 여전히 0이다.value sementic
이라고 한다.
point1
을 사용하고 point2
를 사용하면 함수 실행이 완료된다.스택 포인터
를 다시 증가시켜 point1
및 point2
에 대한 메모리 할당을 간단하게 해제할 수 있다.
point
프로퍼티에 값을 저장하는 대신 point1
과 point2
에 대한 참조 메모리를 할당할 것이다.
point
를 구성할 때 Swift는 힙을 lock
하고 해당 자료 구조 에서 적절한 크기의 사용되지 않은 메모리 블록을 검색한다.
그런 다음 x가 0이고 y가 0인 메모리를 초기화하고 힙의 해당 메모리에 대한 메모리 주소로 point1
참조를 초기화한다 .
힙에 할당할 때 Swift는 실제로 클래스 Point
에 4 word의 스토리지를 할당했다.
이것은 Point
가 구조체일 때 할당된 2 word와 대조적이다.
이것은 이제 Point
가 클래스이고 x 및 y에 대해 저장된 것 외에 Swift가 우리를 대신하여 관리할 두 word를 더 할당 하기 때문이다.
힙 다이어그램에서 파란색 상자로 표시된다.
point1
을 point2
에 할당 할 때 point1
이 구조체였을 때처럼 point
의 내용을 복사 하지 않는다.point1
과 point2
는 실제로 힙에 있는 동일한 point 인스턴스를 참조한다.
point2.x
에 5라는 값을 할당하면 point1.x
와 point2.x
모두 값이 5가 된다.reference sementic
이라고 하며 의도하지 않은 상태 공유로 이어질 수 있다.
point1
을 사용하고 point2
를 사용하면, Swift가 힙을 lock
한 뒤 사용하지 않는 블록 을 적절한 위치로 돌려놓으면서 이 메모리 할당을 해제한다 .
refernce sementic
체계를 갖기 때문에 클래스에는 ID 및 간접 저장과 같은 몇 가지 강력한 특성이 있다.
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
으로 넘어가 보자.
방금까지는 힙 할당에 대해 이야기할 때 세부 사항을 생략했었다.
Swift는 힙에 할당된 메모리를 언제 할당 해제하는 것이 안전한지 어떻게 알까?
대답은 Swift가 힙의 모든 인스턴스에 대한 총 참조 수(reference count)를 유지한다는 것이다.
그리고 인스턴스 자체에 유지한다.
참조를 추가하거나 참조를 제거하면 해당 참조 카운트가 증가하거나 감소한다.
그 수가 0에 도달하면 Swift는 아무도 더 이상 힙의 이 인스턴스를 가리키지 않는다는 것을 알고 해당 메모리를 할당 해제하는 것이 안전하다는 것을 알게 된다.
참조 카운팅에서 염두에 두어야 할 핵심 사항은 이것이 매우 빈번한 작업이며 실제로 정수를 증가 및 감소시키는 것보다 더 많은 것이 있다는 것이다.
첫째, 증가 및 감소를 실행하기 위한 몇 가지 수준의 간접 참조가 존재한다.
그러나 더 중요한 것은 힙 할당과 마찬가지로 여러 스레드가 힙 인스턴스에 참조를 동시에 추가하거나 제거할 수 있기 때문에 고려해야 할 thread safety
가 있다는 것이다. 실제로 참조 카운트를 원자적으로 증가 및 감소 시켜야 한다.
그리고 참조 카운팅 작업의 빈도로 인해 이 비용이 증가될 수 있다.
Point
클래스 프로그램으로 돌아가서 Swift가 실제로 우리를 대신하여 무엇을 하는지 살펴보자.
여기에는 의사 코드가 있다.
Point가 refCount
라는 추가 프로퍼티를 얻었음을 알 수 있다.
그리고 우리는 Swift가 retain 위한 몇 번의 호출과 release를 위한 몇 개의 호출을 추가한 것을 볼 수 있다.
retain은 우리의 참조 카운트를 원자적으로 증가시킬 것이고 release는 우리의 참조 카운트를 원자적으로 감소시킬 것이다.
이런 식으로 Swift는 힙의 Point
에 얼마나 많은 참조가 살아 있는지 추적할 수 있다.
Point
를 구성한 후 해당 Point
에 대한 하나의 살아있는 참조가 있기 때문에 1의 참조 카운트로 초기화 된다는 것을 알 수 있다.
point1
을 point2
에 할당하면 이제 두 개의 참조가 있으므로 Swift는 Point
인스턴스의 참조 횟수를 원자적으로 증가 시키는 호출코드를 추가한다.
point1
사용이 끝나면 Swift는 참조 횟수를 원자적으로 감소시키는 호출 을 추가한다. point1
은 더 이상 실제로 살아있는 참조가 아니기 때문.
point2
사용이 끝나면 Swift는 참조 카운트의 원자적 감소를 추가한다.
Point
인스턴스를 사용하는 참조가 없으므로 Swift는 힙을 lock
하고 메모리 블록을 반환하는 것이 안전하다는 것을 알게 된다.
Point
구조체를 구성할 때 관련된 힙 할당이 없었다.
Point
구조체에 대한 참조 카운팅 오버헤드가 없다.
하지만 더 복잡한 구조체는 어떨까?
여기 String
타입의 text와 UIFont
타입의 font가 포함된 Label
구조체가 있다.
앞에서 들은 것처럼 String
은 실제로 해당 문자의 내용을 힙에 저장한다.
그리고 UIFont
도 클래스이다.
Label
에 두 개의 참조가 있다.
Label
이 실제로 클래스가 가질 수 있는 참조 카운팅 오버헤드의 두 배를 발생 시킬 것임을 알 수 있다.
사용자들은 단순히 문자 메시지를 보내는 데 만족하지 않는다.
Attatchment
가 있다.첨부 파일에 대한 디스크 내 데이터의 경로를 저장하는 fileURL 프로퍼티가 있다.
uuid는 무작위로 생성된 고유 식별자이므로 클라이언트와 서버 및 다른 클라이언트 장치에서 이 첨부 파일을 인식할 수 있다.
JPG, PNG 또는 GIF와 같이 첨부 파일이 나타내는 데이터 타입을 저장하는 mimeType이 있다.
아마도 이 예제에서 중요하지 않은 유일한 코드는 실패할 수 있는 생성자일 것이다. 이 이니셜라이저는 모든 mimeType을 지원하지 않기 때문에 mimeType이 이 앱에 대해 지원되는 mimeType 중 하나인지 확인한다.
우리는 이를 개선시킬 수 있다.
첫째, 앞에서 본 것처럼 uuid는 정말 잘 정의된 개념이다.
그리고 우리는 이 uuid 필드에 아무거나 넣도록 허용하고 싶지 않다.
올해 Foundation
은 uuid에 대해 새로운 value 타입을 추가했다. 이는 해당 128비트를 구조체에 직접 저장하기 때문에 정말 좋다.
이것이 할 일은 문자열이었던 uuid 필드에 대해 지불하는 참조 카운팅 오버헤드를 제거하는 것이다.
그리고 여기에 아무거나 넣을 수 없기 때문에 훨씬 더 엄격한 안전이 확보 되었다.
Attatchment
구조체를 보면 훨씬 더 타입이 안전하다.
컴파일 타임에 실행할 구현(implementation)을 결정할 수 있는 경우 이를 정적 디스패치라고 한다.
그리고 이것은 컴파일러가 실제로 어떤 구현이 실행될 것인지에 대한 가시성을 가질 수 있기 때문에 정말 멋지다.
인라인과 같은 것을 포함하여 이 코드를 매우 적극적으로 최적화 할 수 있다.
이것은 동적 디스패치와 대조된다.
동적 디스패치는 어떤 구현으로 이동할지 직접 컴파일 시간에 결정할 수 없다.
따라서 런타임에 실제로 구현을 찾은 다음 바로 실행한다.
동적 디스패치 자체는 정적 디스패치보다 비용이 많이 들지는 않는다.
여기에는 한 가지 수준의 간접 참조만 있을 뿐이다.
참조 카운팅 및 힙 할당과 같은 스레드 동기화 오버헤드는 없다.
컴파일러는 정적 디스패치에 대해 모든 멋진 최적화(inlining과 optimization)를 수행할 수 있지만 동적 디스패치는 컴파일러의 가시성을 차단 하므로 최적화를 할 수 없다.
Point
로 돌아가 보자.Point
를 가져와서 draw를 호출한다.
Point
를 구성하고 그 point
를 drawAPoint에 전달한다.
런타임에 이 코드를 실행 하면 그냥 Point
를 만든 다음에 함수 구현을 실행하여 완료될 것이다.
두 가지 정적 디스패치 오버헤드와 호출 스택 관련 '설정 및 해제'의 두 가지 오버헤드가 필요하지 않았다.
이것은 정적 디스패치가 왜, 그리고 어떻게 동적 디스패치보다 빠른지 그 이유에 대해 설명한다.
단일 동적 디스패치와 비교하여 단일 정적 디스패치가 큰 차이점을 가지지는 않는다. 하지만 전체 정적 디스패치 체인은 컴파일러가 해당 전체 체인을 통해 가시성을 갖게 된다 .
반면 동적 디스패치 체인은 더 높은 레벨의 모든 추론단계에서 차단 될 것이다.
따라서 컴파일러는 호출 스택 오버헤드가 없는 단일 구현으로 정적 메서드 디스패치 체인을 축소할 수 있다
Drawable
추상 super클래스가 있는 기존의 객체 지향 프로그램을 보면 고유한 커스텀 구현으로 draw를 재정의 하는 Point
하위 클래스와 Line
하위 클래스를 정의할 수 있다.
Drawable
배열을 다형성으로 생성 하고 있다.Point
와 Line
이 포함될 수 있다.
Point
, Line
이 모두 클래스이기 때문에 이러한 것들의 배열을 만들 수 있고 배열에 참조로 저장하기 때문에 크기가 모두 동일하다.
Point
가 될 수도 있고, Line
이 될 수도 있기 때문이다.
컴파일러는 해당 클래스의 타입 정보에 대한 포인터를 클래스에 추가 하고, 그것은 정적 메모리(static?)에 저장된다.
→ Line.Type
메타데이터
그리고 draw를 호출할 때 컴파일러가 실제로 우리를 대신하여 생성 하는 것은, 실행할 올바른 구현에 대한 포인터를 가지는 타입 및 정적 메모리에 있는 virtual method table
이라는 타입을 조회 하는 것이다.
→ 어떤 구현사항을 실행해야 할지 Line.Type
이 알고 있으며 이를 컴파일러가 조회
따라서 이 d.draw를 컴파일러가 우리를 대신하여 수행하는 작업으로 변경하면 실제로 실행할 올바른 draw 구현을 찾기 위해 virtual method table
을 검색하는 것을 볼 수 있다.
그런 다음 실제 인스턴스를 암시적 self
매개변수로 전달한다.
자, 우리는 여기서 무엇을 보았는가?
클래스는 기본적으로 메서드를 동적으로 dispatch한다.
이것은 그 자체로 큰 차이를 만들지 않지만 메서드 체인 및 기타 사항과 관련하여 인라인과 같은 최적화를 방지 할 수 있고 합산될 수 있다.
그러나 모든 클래스에 동적 디스패치가 필요한 것은 아니다.
final
로 표시하여 팀원과 미래의 나에게 그것이 의도한 것임을 전달할 수 있다.
그렇다면 우리는 이제 어떻게 해야하는가?
이 강연의 전반부에서 자기 자신에게 던져야 하는 질문이 있었다.
필요하지 않은 dynamism(역동성)을 위해 비용을 지불한다면 성능이 저하될 것이다.
그리고 Swift를 처음 사용하거나 Objective C 에서 Swift 로 이식된 코드 기반에서 작업 하는 경우 현재보다 구조체를 더 많이 활용할 수 있다.
예제에서 본 것처럼 문자열 대신 구조체를 사용했던 것들과 같이.
그러나 한 가지 질문은 "구조체를 사용하여 다형성 코드를 작성 하는 방법은 무엇입니까?"이다.
우리는 아직 그것을 보지 못했다.
답은 프로토콜 지향 프로그래밍이다.
Drawable
추상 base 클래스 대신 이제 draw 메서드를 선언한 Drawable
프로토콜이 있다.struct Point
및 struct Line
이 있다.
SharedLine
클래스를 가질 수도 있다.reference semantic
체계가 의도하지 않은 공유를 가져다주므로 그렇게 하지 않기로 결정했다.
Drawable
프로토콜 타입 배열에 Point
타입과 Line
타입의 값을 모두 저장할 수 있다.
value type struct Line
과 struct Point
는 이전에 본 메커니즘인 V-Table 디스패치를 수행하는 데 필요한 공통 상속 관계를 공유하지 않는다.
그렇다면 Swift는 어떻게 올바른 메소드로 디스패치할까?
Protocol Witness Table
이라는 테이블 기반 메커니즘이다.
어떻게 값들을 일관적으로 저장할까?
일단 Line
과 Point
가 value type이라는 것을 명심하자.
Line
은 4 word가 필요하다.
Point
는 2 word가 필요하다.
그러나 우리 배열은 배열의 고정 오프셋(고정된 공간)에 균일하게 요소를 저장하려고 한다.
어떻게 작동할까?
Existential Container
라는 특별한 스토리지 레이아웃을 사용한다는 것이다.
Existential Container
의 처음 세 word 는 valueBuffer용으로 예약되어 있다.
Point
와 같은 작은 타입은 이 valueBuffer에 맞는다.Line
은?
이 경우 Swift는 힙에 메모리를 할당하고 거기에 값을 저장한 뒤 해당 메모리에 대한 포인터를 Existential Container
에 저장한다.
이제 Line
과 Point
사이에 차이가 있음을 확인했다.
따라서 어떻게든 Existential Container
는 이 차이를 관리해야 한다.
어떻게 그렇게 하나요?
이에 대한 대답은, 다시 테이블 기반 메커니즘이다.
Value Witness Table
이라고 부른다.Value Witness Table
은 value 수명을 관리하며 타입별로 이러한 테이블 중 하나를 가진다.
Line
Value Witness Table
이 있으므로) 힙에 메모리 를 할당 하고 Existential Container
의 valueBuffer 내부 에 해당 메모리에 대한 포인터를 저장할 것이다.Existential Container
로 복사해야 한다 .
Line
이 있으므로 Value Witness Table
의 복사 항목은 올바른 작업을 수행하고 이를 힙에 할당된 valueBuffer에 복사한다.
Value Witness Table
에서 파괴 항목을 호출한다. 그러면 타입에 포함되어 있었을 참조 카운트가 감소한다.Line
에는 아무것도 없으므로 이제 필요하지 않다.
Line
에 대한 Value Witness Table
이 있으므로 이것은 값에 대해 힙에 할당된 메모리를 할당 해제할 것이다.
Value Witness Table
의 다음 항목은 참조이다.Existential Container
에는 Value Witness Table
에 대한 참조 가 있다.Protocol Witness Table
에 도달하는 방법은 무엇일까?
이 역시 Existential Container
에서 참조된다.
우리는 Swift가 프로토콜 타입의 값을 관리하는 방법의 메커니즘 을 보았다.
Existential Container
가 실제로 어떻게 작동하는지 예제를 통해 살펴보자.
Drawable
프로토콜 타입의 지역변수를 생성하고 Point
로 초기화한다.
Existential Container
는 valueBuffer에 대한 세 word 저장소와 value witness 및 protocol witness table에 대한 참조를 가진 구조체이다.
Existential Container
를 전달하는 것을 볼 수 있다.
Existential Container
를 할당한다.
existential container
에서 value witness table
과 protocol witness table
을 읽고 local existential container
필드를 초기화한다.
local existential container
의 valueBuffer로 복사한다.
Line
을 전달받았다면 이 함수는 버퍼를 할당하고 거기에 값을 복사했을 것이다.
existential container
의 필드에서 protocol witness table
을 찾은 뒤 해당 테이블의 고정 오프셋에서 draw 메서드를 찾은 다음 구현부분으로 이동한다.
projectBuffer
existential container
의 시작위치이고 인라인 valueBuffer에 맞지 않는 큰값이 있는 경우 힙에 있는 주소가 시작위치이다.
함수 실행이 완료되고 스택이 제거되면서 스택에 생성됐던 local existential container
가 제거된다 .
이 작업에서 주목하고자 하는 한 가지는 struct Line
및 struct Point
와 같은 value 타입을 프로토콜과 결합하여 동적 동작, 동적 다형성을 얻을 수 있다는 것이다.
Drawable
프로토콜 타입의 배열에 Line
과 Point
를 저장할 수 있었다.
이 역동성이 필요하다면 이것은 꽤 괜찮은 선택이다. 이전에 본 예제의, 클래스를 사용하는 것과 비교해봤을 때 클래스는 V-Table을 통해야 하며 참조 카운팅 추가 오버헤드가 있었기 때문이다.
여기까지 우리는 지역 변수가 복사되는 방식과 프로토콜 타입 value에 대해 메서드 디스패치가 작동하는 방식을 살펴보았다.
Drawable
프로토콜 타입의 first와 second 두 저장 프로퍼티를 가진 Pair
라는 구조체가 있다.
Pair
를 할당할 때를 보면 Swift는 둘러쌀 구조체 인라인에 해당 쌍을 저장하는 데 필요한 두 개의 existential container
를 저장할 것이다.
Line
과 Point
로 pair
를 초기화하고 이전에 보았듯이 Line
에 대해 힙에 버퍼를 할당한다.Point
는 인라인 valueBuffer에 맞고 existential container
인라인에 저장할 수 있다.
Line
을 저장하려 한다고 해보자.
Line
을 만들고 Pair
를 만든 뒤 이 Pair
를 Line
으로 초기화하였다.
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보다 클 것이라고 알고 있다.
따라서 이 경우 인스턴스에 쓰기 전에 인스턴스를 복사한 다음 해당 복사본에 쓴다.
이렇게 하면 상태가 분리된다.
이제 Line
을 위해 이것을 어떻게 할 수 있는지 살펴보자.
Line
내부에 Storage를 직접 구현하는 대신 Line
구조체의 모든 필드를 포함하는 LineStorage
라는 클래스를 만든다.
그런 다음 Line
구조체가 이 Storage를 참조한다.
그리고 값을 읽고 싶을 때마다 단지 그 저장소 안의 값을 읽는다.
그러나 값을 수정하거나 변경 하려면 먼저 참조 횟수를 확인한다.
1보다 큰가?
isUniquelyReferenced
호출이 달성한다.1보다 크거나 같은가?
LineStorage
의 복사본을 만들고 그것을 변경한다.우리는 구조체와 클래스를 결합하여 Copy on Write
를 사용하는 간접 저장소를 얻는 방법을 알아보았다 .
이번에는 간접 저장을 사용할 때 어떤 일이 발생하는지 보기 위해 예제로 돌아가 보자.
Line
을 생성한다.LineStorage
객체를 생성할 것이다.Line
을 사용하여 Pair
를 초기화한다.
LineStorage
에 대한 참조만 복사된다.
Line
을 다시 복사하려고 하면 참조만 복사 되고 참조 카운트가 증가하게 된다.
existential container
의 인라인 valueBuffer에 들어갈 수 있는 작은 값을 포함하는 프로토콜 타입은 힙 할당이 없다.
구조체에 참조가 포함되어 있지 않으며 참조 카운팅도 없다.
그러나 value witness 및 protocol witness table을 통한 간접방식이므로 동적 디스패치의 모든 기능을 얻을 수 있으므로 동적으로 다형성 동작이 가능하다.
이것을 큰 값을 가진 것과 비교해보자.
그러나 Copy on Write
와 함께 간접 저장소를 사용함으로써 값비싼 힙 할당을 대신 사용할 수 있는 기술을 보여주었다.
이것은 클래스를 사용하는 것과 유리하게 비교된다.
클래스에는 참조 카운팅도 발생한다.
Line
과 Point
를 저장할 수 있었다.protocol
및 value witness table
과 existential container
를 사용하여 달성된다.Copy on Write
를 사용해 구조체를 구현함으로써 이 문제를 해결 하는 방법을 보여주었다.
Drawable
프로토콜 타입의 매개변수를 복사해야 했다.
Line
에서 사용했었다.
나중에 프로그램에서는 Point
에 대해 쓸 수도 있다.
여기에 generic 코드를 사용할 수 있을까?
가능하다!
한번 살펴보도록 하자.
Drawable
이라는 generic 매개변수 제약 조건을 취하고 있으며 나머지 부분은 이전과 동일하다.
generic 코드는 매개변수 다형성이라고도 하는, 보다 정적 형태의 다형성을 지원한다.
이게 무슨말인가?
이 예를 살펴보자.
Drawable
제약 조건을 가진 T라는 generic 매개변수를 취하는 함수 foo가 있다. 그리고 이 함수는 해당 매개변수를 함수 bar에 전달한다.
이 bar 함수는 다시 generic 매개변수 T를 사용한다.
그런 다음 프로그램은 Point
를 만들고 이 point
를 foo 함수에 전달한다.
Point
)에 바인딩한다.Point
를 갖는다.
따라서 다시 이 호출 컨텍스트의 generic 매개변수 T는 Point
타입으로 바인딩된다.
여기서 볼 수 있듯이 타입은 매개변수를 따라 호출 체인 아래로 대체되어 나간다.
이것이 보다 정적인 형태의 다형성 또는 매개변수적 다형성을 의미한다.
이제 Swift가 내부적으로 이것을 구현하는 방법을 살펴보자.
Point
를 전달한다.
protocol
및 value witness table
을 사용하여 일반적으로 해당 함수 내부의 작업을 수행한다.
existential container
를 사용하지 않는다.
Point
타입의 value witness table
과 protocol witness table
을 함수에 대한 추가 인자로 전달할 수 있다.Point
와 Line
에 대한 value witness table
이 전달 되었음을 알 수 있다.
value witness table
을 사용하여 잠재적으로 힙에 필요한 버퍼를 할당하고 할당 소스(source of assignment)에서 목적지로 복사를 실행한다.
그리고 로컬 매개변수에 대해 draw 메서드를 실행할 때와 유사하게, 전달된 protocol witness table
을 사용하고 테이블 내의 고정 오프셋의 draw 메서드를 찾아 구현으로 이동한다.
여기에는 existential container
가 없다고 방금 말했다.
그렇다면 Swift는 이 지역 매개변수를 위해 생성된 지역 변수에 대해 필요한 메모리를 어떻게 할당할까?
Point
와 같은 작은 값은 valueBuffer에 맞는다.
Line
과 같은 큰 값은 다시 힙에 저장되고 local existential container
내부에 해당 메모리에 대한 포인터를 저장한다.
그리고 이 모든 것은 value witness table
사용을 통해 관리된다.
이제 "여기에서 프로토콜 타입을 사용하지 않았을 수 있었을까" 라는 질문을 던질 수 있다.
이 정적 형태의 다형성은 제네릭의 특수화라고 하는 컴파일러 최적화를 가능하게 한다.
한 번 살펴보자.
Point
를 전달한다.
Swift는 해당 타입을 사용하여 함수의 generic 매개변수를 대체하고 해당 타입에 고유한 해당 함수 버전을 생성한다.
따라서 여기에 Point
타입의 매개변수를 사용하는 Point
함수 drawACopy가 있으며 해당 함수 내부의 코드는 다시 해당 타입에 대해 고유하다.
그리고 Kyle이 보여준 것처럼 이것은 정말 빠른 코드가 될 수 있다.
Swift는 프로그램의 호출 사이트에서 사용되는 타입별로 버전을 생성한다.
따라서 Point
와 Line
에 대해 drawACopy함수를 호출하면 해당 함수의 두 가지 버전으로 맞춤화하고 생성한다.
잠깐! 이것은 코드 크기를 많이 증가시킬 가능성이 있다.
그러나 사용할 수 없는 정적 타이핑 정보는 적극적인 컴파일러 최적화를 가능하게 하기 때문에 Swift는 실제로 잠재적인 방향으로 코드 크기를 줄일 수 있다.
Point
메서드 함수의 drawACopy를 인라인한다.
이제 Point
메서드의 drawACopy가 더 이상 참조되지 않으므로 컴파일러는 이를 제거 하고 Line
예제에 대해 유사한 최적화를 수행한다.
따라서 이 컴파일러 최적화가 반드시 코드 크기를 증가시키는 것은 아니다.
specialization이 어떻게 작동하는지 살펴 보았지만 한 가지 질문은 "언제 발생합니까?"이다.
아주 작은 예를 살펴보자.
Point
를 정의한 다음 해당 타입의 지역 변수를 생성하였다.
point
는 이를 Point
로 초기화한 다음 해당 Point
를 drawACopy함수에 대한 인자로 전달한다.
이제 이 코드를 specialization하기 위해 Swift는 이 호출 site에서 타입을 유추 할 수 있어야 한다.
로컬 변수를 보고, 초기화로 돌아가 Point
로 초기화되었는지 확인할 수 있기 때문에 그렇게 할 수 있다.
또한 Swift는 specialization 중에 사용되는 타입과 그 자체로 사용 가능한 generic 함수에 대한 정의가 있어야 한다.
이번에도 마찬가지이다.
모두 하나의 파일에 정의되어 있다.
이것은 전체 모듈 최적화(Whole Module Optimization)
가 최적화 기회를 크게 향상시킬 수 있는 곳이다.
왜 그런지 살펴보도록 하자.
Point
정의를 별도의 파일로 옮겼다고 가정해 보도록 하겠다.Point
정의를 더 이상 사용할 수 없다.
Point
파일 정의에 대한 통찰력을 갖게 되며 최적화가 발생할 수 있다.
Drawable
프로토콜 타입 쌍을 갖고 있다.
Pair
를 만들고 싶을 때마다 실제로는 같은 타입의 쌍, 예를 들어 Line
쌍 또는 Point
쌍을 만들기를 원했다.
Line
에 대한 storage에는 두 개의 힙 할당이 필요 하다는 것을 기억해라.
따라서 쌍을 제네릭으로 정의한 다음 해당 제네릭 타입의 first 및 second 프로퍼티가 이 제네릭 타입을 갖도록 하면 컴파일러는 실제로 동일한 타입의 쌍만 생성 하도록 강제할 수 있다.
이렇게 하면 나중에 프로그램에서 한 쌍의 Line
묶음에 Point
를 저장할 수 없게 된다.
이것이 우리가 원했던 것이다. 그러나 이것은 성능이 더 좋을까 나쁠까?
한 번 살펴보자.
여기 Pair
가 있다.
이번에는 저장 프로퍼티가 generic 타입이다.
런타임에 타입을 변경할 수 없다고 말한 것을 기억하자.
generated code가 의미하는 바는 Swift가 enclosing 타입의 저장소를 인라인으로 할당할 수 있다는 것이다.
따라서 Line
쌍을 만들 때 Line
에 대한 메모리는 실제로 enclosing 쌍의 인라인으로 할당된다.
추가 힙 할당이 필요하지 않다.
value witness
및 protocol witness table
을 사용하여 specialized되지 않은 코드가 작동하는 방식과 컴파일러가 generic 함수의 타입별 버전을 생성하는 코드를 specialize할 수 있는 방법을 살폈다.
이 경우, 방금 보았듯이 generated code는 본질적으로 이 함수를 구조체로 작성한 것과 같기 때문에 구조체 타입을 사용하는 것과 동일한 성능 특성을 갖는다.
구조체 타입의 값을 복사할 때 힙 할당이 필요하지 않다.
구조체에 참조가 포함되어 있지 않으면 참조 카운팅이 없다.
또한 컴파일러 최적화를 추가로 가능하게 하고 런타임 실행 시간을 줄이는 정적 메서드 디스패치를 가지고 있다.
큰 값과 generic 코드를 사용하는 경우 힙 할당이 발생한다.
그러나 이전에 간접 저장 기술을 해결 방법으로써 사용 하는 것을 보여줬었다.
큰 값에 참조가 포함된 경우 참조 카운팅이 생기고, 동적 디스패치의 힘을 얻는다. 즉, 코드 전체에서 하나의 일반 구현을 공유할 수 있다.
그래서 우리는 오늘 구조체와 클래스의 성능 특성이 어떻게 생겼는지, generic 코드가 어떻게 작동하는지, 프로토콜 타입이 어떻게 작동하는지 보았다.
이것에서 무엇을 얻을 수 있을까?
자, 드디어 마지막 본론으로 가보자.
동적 런타임 타입 요구 사항이 가장 적은, 엔티티에 적합한 추상화를 선택하자.
이렇게 하면 정적 타입 검사가 가능하고 컴파일러는 컴파일 타임에 프로그램이 올바른지 확인할 수 있다.
또한 컴파일러는 코드를 최적화하기 위한 더 많은 정보를 가지고 있으므로 더 빠른 코드를 얻을 수 있다.
따라서 구조체 및 열거형과 같은 값 타입을 사용하여 엔티티를 표현할 수 있다면 value semantic 체계를 얻을 수 있으며 이는 의도하지 않은 상태 공유가 없고 최적화 가능한 코드를 얻을 수 있다.
엔티티에 있어, 또는 객체 지향 프레임워크로 작업을 해야해서 클래스를 사용해야 하는 경우 Kyle은 참조 카운팅 비용을 줄이는 몇 가지 기술을 보여주었었다.
프로그램 일부를 보다 정적인 형태의 다형성을 사용하여 표현할 수 있는 경우 generic 코드를 값 타입과 결합할 수 있으며 매우 빠른 코드를 얻을 수 있으면서도 해당 코드에 대한 구현도 공유할 수 있다.
그리고 Drawable
프로토콜 타입 예제의 배열과 같이 동적 다형성이 필요한 경우 프로토콜 타입을 값 타입과 결합하여, 클래스를 사용하는 것보다 비교적 빠른 코드를 얻을 수 있으면서도 여전히 value semantic을 유지할 수 있다.
그리고 프로토콜 타입 또는 제네릭 타입 내부에서 큰 값을 복사하기 때문에 힙 할당에 문제가 발생하는 경우 해당 기술, 즉 Copy on Write
와 간접 저장을 사용하여 이 문제를 해결하는 방법을 보여줬었다.
zeddiOS - Understanding Swift Performance 1
와 이걸 하다니 대박이다