iOS/WWDC

WWDC16) Understanding Swift Performance - 2

Brad_Heo 2023. 5. 2. 11:28

프로토콜 유형과 제네릭 코드 구현을 탐색

프로토콜 유형 변수가 저장되고 복사되며 메소드 디스패치가 작동하는 방식을 알아 볼 겁니다.

여기 Drawable 추상 기본 클래스 대신, draw 메소드를 선언하는 protocol drawable이 있습니다.

또한 값 타입인 구조체 Point와 Line이 프로토콜을 준수하고 있습니다.
이제 값타입도 다형성을 가지게 되었습니다. 우리는 drawable프로토콜 타입의 배열에 Point와 Line 타입의 값을 모두 저장할 수 잇습니다. 그러나 이전과 비교했을 때, 다른 점이 있습니다.

참고로 값 유형 구조체인 Line과 point구조체는 Kyle이 방금 보여준 V-Table 디스패치를 수행하는데 필요한 공통 상속 관계를 공유하지 않습니다. 그렇다면 이 메서드에 대한 디스패치를 어떻게 수행할까요?

이 경우 배열을 확인하는 동안 수행 됩니다.

이 질문의 답은 프로토콜 감시 테이블이라는 테이블 기반 메커니즘입니다.

당신의 애플리케이션에서 프로토콜을 구현하는 각 유형별로 테이블이 하나씩 있습니다. 그 테이블의 항목들은 해당 타입에서의 구현으로 링크 됩니다. 이제 메소드를 찾는 방법을 알게 되었습니다.

하지만 여전히 궁금중이 있습니다.

"배열의 요소에서 표로 어떻게 이동하나요?"

이제 Line과 Point라는 값 유형이 있습니다.

우리 Line에는 네 개의 단어가 필요합니다.

Point는 2개의 단어가 필요하죠. 그들은 같은 크기를 가지고 있지 않습니다. 그러나 우리의 배열은 배열의 고정된 오프셋에서 균일하게 요소를 저장하려고 합니다.

어떻게 작동할까요?

이 질문에 대한 답은 Swift가 Existential Container라는 특별한 저장소 레이아웃을 사용한다는 것 입니다.

이것은 무엇일까요?

Existential 컨테이너의 처음 세 개의 단어는 valueBuffer를 위해 사용되었습니다.

저희 Point와 같은 작은 유형은 두 개의 단어만 필요하기 때문에 이 valueBuffer에 맞습니다.

그럼 Line은 어떻게 될까요? 네 개의 단어가 필요합니다. 어디에 넣어야 할까요?

이 경우 Swift가 힙에 메모리를 할당하고 값을 저장하고 Existential Container에 그 메모리의 포인터를 저장 합니다.

Value Withness Table은 우리의 수명을 관리하며, 프로그램당 하나의 해당 테이블이 있습니다.

이제 이 표가 어떻게 작동하는 보기 위해 지역 변수의 수명을 살펴보겠습니다.

우리는 프로토콜 형식의 로컬 변수의 수명이 시작될떄 Swift는 해당 테이블 내의 allocate함수를 호출 합니다.

이 함수는 Line Value Witness Table이 있기 때문에, 힙에서 메모리를 할당하고 그 메모리에 대한 포인터를 존재하는 컨테이너의 valueBuffer에 저장합니다.

다음으로 Swift는 지역 변수를 초기화하는 할당이 소스에서 값 복사를 existential 컨테이너로 복사해야 합니다. 다시 말하지만, 여기에는 Line이 있으므로 값 witness테이블의 복사 항목은 올바른 작업을 수행하여 힙에 할당된 valueBuffer로 복사합니다.

이제 프로그램이 실행되고 로컬 변수의 라이프타임이 끝났으면, Swift는 값 witness table의 destruct entry를 호출하게 되며, 이는 우리 타입에 포함할 수 있는 값들에 대한 참조 카운트를 감소합니다.

지금까지 스위프트가 다양한 유형의 값들을 일반적으로 다룰 수 있는 메커니즘을 보았습니다. 그러나 어떻게 그 테이블들에 접근을 할까요??

답은 분명합니다.

Value Witness Table에서의 다음 항목은 참조 입니다.

존재하는 컨테이너에는 Value Witness Table에 대한 참조가 있습니다.

마지막으로 우리는 우리의 Protocol Witness Table에 어떻게 접근할까요?

그것은 다시 한번, 존재하는 컨테이너에 참조되어 있습니다.

지금가지 우리는 Swift가 프로토콜 타입의 값을 어떻게 관리하는지에 대한 매커니즘을 보았습니다. 이제 실제 예제를 살펴보면서 Existential Container가 어떻게 동작하는지 살펴봅시다.

이 예제에서는 프로토콜 타입 매개변수 local을 받는 함수가 있고, 해당 프로토콜의 draw메서드를 실행 합니다. 그리고 프로그램에서는 Drawable 프로토콜 타입의 지점으로 초기화된 로컬 변수를 생성합니다.

스위프트 컴파일러가 생성하는 코드를 설명하기 위해 이 예제 아래에 스위프트를 의사 코드 표기법으로 사용할 것 입니다.

Value Witness Table에 대해서는 값 버퍼와 Value와 ProtocolWitnessTable에 대한 참조를 가진 구조체를 사용 합니다.

drawACopy함수 호출이 실행되면 인자를 받아들이고 함수에 전달합니다.

그래서 생성된 코드에서 Swift는 힙에 존재하는 Existential Conainer에 할당합니다. 그 다음 Witness Table과 Protocol Witness Table을 읽고 지역적인 Existential Container를 읽고 local Existntial Container를 초기화합니다. 그 다음 필요한 경우 값을 복사하고 버퍼를 할당하기 위해 값 Witness 함수를 호출합니다.

이 예제에서는 Point을 전달했기 떄문에 동적 힙 할당이 필요하지 않습니다.

이 함수는 인자로부터 값을 복사해 로컬 Existential Container의 valueBuffer에 저장합니다.

그러나 Line에 전달했다면, 이 함수는 버퍼를 할당하고 값을 복사했을 것입니다.

그 다음 draw 메소드가 실행되고, Swift는 Existential Container의 필드에서 프로토콜 위험 테이블을 조회하고 그 테이블에서 고정 오프셋의 draw메소드를 조회하여 구현으로 이동합니다.

잠시 시간을 가져보죠

다른 유용한 winess 호출이 있습니다. projectBuffer 입니다.

draw메소드는 입력으로 값을 가리키는 주소를 기대합니다. 그리고 우리의 값이 인라인 버퍼에 들어가는 작은 값인지, 아니면 인라인 값 버퍼에 들어가지 않는 큰 값인지에 따라, 이 주소는 Existential Container일수도 있고, 힙에서 할당된 메모리의 시작일 수도 있습니다.

그래서 이 Value Witness 함수는 타입에 따라 차이점을 추상화 합니다. 그리고 draw메소드가 실행되고 끝나면 우리는 이제 함수의 끝에 도달하게 되고 이는 매개변수로 생성된 로컬 변수가 스코프 밖으로 나가게 되는 것을 의미합니다.

그래서 Swift는 값이 파괴될 때 값이 증가시킨 경우 참조 카운트를 감소시키고 버퍼가 할당되었다면 버퍼를 해제하는 값을 증명하는 함수를 호출합니다.

우리 함수는 실행을 마치고 스택이 제거됩니다. 이제 스택에 생성된 로컬 Existential Container를 제거합니다.

이 작업이 가능하게 하는 것은 구조체 Line, Point와 같은 값 유형을 프로토콜과 결합하여 동적 동작, 다형성을 얻는 것 입니다.

우리는 Drawable 프로토콜 유형의 배열에 Line과 Point를 저장할 수 있습니다.

만약 당신이 이러한 동적성이 필요하다면, 이것은 Kyle이 보여준 예제에서 클래스를 사용하는 것과 비교하여 V-테이블을 통과하고 참조 계수의 추가적인 오버헤드가 있기 때문에 지불할 만한 좋은 가격입니다.

자 지금까지 로컬 변수가 복사되는 것과 프로토콜 타입 값의 메서드 디스패치가 작동하는 것을 보았습니다. 이제는 저장 프로퍼티를 살펴봅시다.

이 예제에서는 drawable 프로토콜 타입의 프로토콜 first와 second라는 두 개의 저장 프로퍼티를 포함하는 pair가 있습니다.

Swift는 두 개의 저장 프로퍼티를 어떻게 저장할까요? 포함하는 구조체의 인라인에 저장됩니다.

우리가 Pair를 할당할때 Swift는 그 paire을 저장하기 위해 필요한 두 Existential Container를 포함하는 구조체의 내부에 인라인으로 저장합니다. 이후 우리의 프로그램은 Line과 Point의 쌍을 초기화 합니다. 이전에 본 것처럼 Line에 대해 우리는 힙에 버퍼를 할당할 것 입니다. Point는 인라인 값 버퍼에 들어가며 Existential Container 내부에 인라인으로 저장될 수 있습니다. 이 표현은 프로그램에서 나중에 다른 형식의 값을 저장할 수 있도록 합니다. 그러면 프로그램은 두번쨰 요소에 Line을 저장 합니다. 이 방법은 동일하지만 이제 우리는 두개의 힙 할당이 잇습니다.

자 이제 두 개의 힙 할당 입니다.

힙 할당 비용을 보여주는 다른 프로그램을 살펴보겠습니다.

다시한번 우리는 Line을 만들고, pair을 만들고, 이를 Line으로 초기화합니다. 그래서 우리는 하나, 두 개의 힙 할당이 있습니다. 그런 다음 pair의 복사본을 만들고, 스택에 두 개의 존재 수용기와 두개의 힙 할당이 있습니다.

"4개의 힙 할당"이라는 의문점이 생길 것 입니다.

이것에 대해 무언가를 할 수 있을까요

우리는 Existential Container는 세 개의 단어를 담을 수 있는 자리가 있으며 참조는 기본적으로 하나의 단어입니다.

그래서 만약 Line을 클래스를 이용해 구현했다면 참조로 저장됩니다. 이참조는 valueBuffer에 맞출 수 잇을 것 입니다. 그리고 pair에서 두번째 필드로 첫번째 참조를 복사할 떄, 참조만 복사되고, 우리가 내는 비용은 참조 카운트 증가만 있습니다.

다시 의도치 않은 상태 공유가 될 수 있습니다.
따라서 우리가 Pair의 두번쨰 필드를 통해 x1필드에 저장하면, 첫 번쨰 필드는 변경 사항을 관찰할 수 있습니다.

이것은 우리가 원하는 것이 아닙니다.

우리는 값을 원합니다.

해결책이 있습니다. 바로 복사 및 쓰기 기술 이죠

우리는 클래스에 쓰기 전 참조 횟수를 확인 합니다.

우리는 동일한 인스턴스에 대해 둘 이상의 참조가 있을 때 참조 카운트가 1~5이상이 될 것으로 알고 있습니다. 그러므로 이 경우, 인스턴스에 쓰기 전 인스턴스를 복사하고 그 사본에 쓰도록 합시다. 이렇게 하면 상태가 분리됩니다.

이제 Line에 대해 어떻게 할 수 있는 살펴보겠습니다.

우리는 Line내부에 저장소를 직접 구현하는 대신 모든 Line 구조체 필드를 가진 LineStorage 클래스를 만듭니다. 그리고 Line구조체는 이 저장소를 참조합니다.

우리가 값을 읽고자 할떄 우리는 저장소안의 값을 읽기만 하면 됩니다.

그러나 값 수정 또는 변형을 하기 전 참조 횟수를 확인 합니다. 참조 횟수가 1보다 큰지 확인 합니다. 이것은 isUniquelyReferenced호출이 달성하는 것 입니다. 이것은 하는 일은 참조 횟수를 확인하는 것 입니다.

참조횟수가 1보다 크게 되면 Line 스토리지는 사본을만들고 해당 사본을 변형 합니다.

여기까지 우리는 구조체와 클래스를 결합해서 복사 및 쓰기를 사용해 간접 저장소를 얻을 수 있는 방법을 볼 수 있었습니다.

이번엔 간접 저장소를 사용하여 이전 예제를 다시 봅시다.

다시 Line을 만듭니다.

이렇게 하면 힙에 Line 스토리지 객체가 생성됩니다. 그리고 그 Line을 사용하여 우리의 Pair을 초기화합니다. 이번에는 라인 스토리지에 대한 참조만 복사됩니다.

Line을 복사하면 참조만 복사되고 참조 카운트가 증가합니다. 이것은 힙 할당 보다 훨씬 비용이 적습니다.

네. 우리는 프로토콜 유형의 변수가 복사되고 저장되는 방식과 메소드 디스패치가 작동되는 방식을 보았습니다.

이제 성능에 대한 의미를 살펴보겠습니다.

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

구조체가 참조를 포함하지 않는다면 레퍼런스 카운팅도 없습니다.

그리고 값 witness, 프로토콜 witness table 통해 간접적인 접근 덕분에 동적 디스패치의 기능을 사용해 다형성 행동이 가능합니다.

큰 값과 비교해봅시다. 큰 값은 프로토콜 타입의 변수를 초기화하거나 할당될 떄마다 힙 할당이 발생합니다. 우리의 큰 값 구조체가 참조를 포함한다면 참조 계산도 되어집니다.

하지만 간접 저장소를 사용하여 복사 및 쓰기를 함으로써 비싼 힙 할당을 대체할 수 있는 기술을 보여줬습니다.

더 비용이 적은 레퍼런스 카운팅을 이용해서요.

다시 어플리케이션을 돌아와서 살펴봅시다.

Gereric Code

DrawACopy 메서드는 이제 Drawable로 제네릭 매개 변수 제약 조건을 사용하며, 프로그램의 나머지 부분은 동일 합니다.

차이점이 있을까요?

제네릭 코드는 매개변수 다형성으로 불리는 것 보다 정적인 형태의 다형성을 지원합니다.

하나의 호출 컨텍스트당 하나의 유형이 있습니다.

이게 무슨 의미일까요

우리는 제네릭 파라미터 T를 drawable로 제한한 함수 foo가 있습니다. 이함수는 이 파라미터를 함수 bar로 전달합니다.

이 함수가 실행되면 Swift는 호출 측에서 사용된 유형인 Point에 일반 유형 T를 바인딩 합니다. 이 바인딩으로 foo함수가 실행되고 bar함수 호출에 도달하면 이 지역 변수는 방금 찾은 유형 즉 Point유형을 갖게 됩니다. 그래서 다시 한번 이 호출 컨텍스트에서 일반 매개 변수 T가 Point유형을 통해 바인딩됩니다. 우리가 볼 수 있듯이 타입은 하향식으로 대체 됩니다. 이것은 정적 형태의 다형 또는 매개 변수 다형성이라고 하는 것 입니다. 그래서 Swift가 이를 어떻게 구현하는지 살펴 보겠습니다. 다시 drawACopy함수로 돌아와 봅시다

이 예제에서는 Point을 전달합니다.

프로토콜 유형을 사용할 때와 마찬가지로 하나의 공유 구현이 있습니다. 그리고 이 공유 구현은 프로토콜 타입을 사용할 때와 마찬가지로, 해당 함수 내에서 일반적으로 작업을 수행하기 위해 프로토콜과 값 witness table을 사용 합니다.

하지만 호출 컨텍스트당 하나의 타입을 가지기 떄문에 스위프트는 여기서 Existential Container를 사용하지 않습니다.

대신 이 함수에 사용되는 타입의 값 Witness Table과 프로토콜 witness table을 추가 인수로 전달할 수 있습니다.

따라서 이 경우 Point 및 Line의 Witness table이 전달된 것을 볼 수 있습니다.

그리고 그 함수를 실행하는 도중, 우리가 파라미터를 위한 로컬 변수를 생성할 때, Swift는 값 witness 테이블을 사용하여 필요한 버퍼를 힙에 할당하고 할당의 대상인 소스에서 대상으로 복사를 실행합니다. 그리고 로컬 파라미터에서 draw 메소드를 실행할 때도, 전달된 프로토콜 witness 테이블을 사용하여, 테이블에서 고정된 오프셋의 draw 메소드를 찾아 해당 구현으로 점프합니다.

현재 존재하고 있는 컨테이너가 없습니다.

그렇다면 스위프트는 어떻게 변수를 저장할 메모리를 할당할까요

이는 스택에 valueBuffer에 할당하게 됩니다. valuBuffer는 3단어이고 Point와 같은 작은 값은 valueBuffer에 맞습니다.

우리 Line과 같은 큰 값들은 다시 힙에 저장되고, 우리는 로컬 existential 컨테이너 내부에 그 메모리에 대한 포인터를 저장합니다.

이제 빨라져나요? 더 좋아졌나요?

우리는 정적 다형성을 사용하기 위해 호출 지점에서 하나의 타입이 잇습니다. Swift는 그 타입을 사용하여 함수 내의 제네릭 매개변수를 대체하고 해당 타입에 특화된 함수 버전을 생성합니다.

그렇게 되면 이제 Point 타입의 매개변수를 받는 drawACopy함수가 있으면 이것은 해당 타입에 특화되어 있습니다.

이것은 정말 빠른 코드가 될 수 있습니다.

Swift는 프로그램에서 사용되는 각 유형에 대해 호출 사이트별로 버전을 생성합니다. 따라서 Line에서 Point에있는 drawACopy 함수를 호출하면이 함수의 두 가지 버전을 전문화하여 생성합니다. 이제 "잠깐만 기다려보세요. 이것은 코드 크기를 많이 증가시킬 수 있는 가능성이있는 것이 아닌가요?"라고 말할 수 있습니다.

그러나 사용할 수없는 정적 타이핑 정보는 공격적인 컴파일러 최적화를 가능하게하므로 Swift는 여기에서 실제로 코드 크기를 줄일 수 있습니다. 예를 들어 Point 메서드의 drawACopy를 인라인으로 처리 한 다음 이제 많은 컨텍스트가 있으므로 코드를 더욱 최적화합니다.

이제 이 두 파일을 따로 컴파일하면 UsePoint 파일을 컴파일할 때 Point의 정의가 더 이상 사용할 수 없습니다. 왜냐하면 컴파일러가 이 두 파일을 따로 컴파일했기 때문입니다.

Swift는 특수화 중 사용되는 유형과 제네릭 함수 자체의 정의를 모두 가져야합니다. 다시 말해, 이 경우에도 하나의 파일에 모두 정의되어 있습니다.

이제 이 두 파일을 따로 컴파일하면 UsePoint 파일을 컴파일할 때 Point의 정의가 더 이상 사용할 수 없습니다. 왜냐하면 컴파일러가 이 두 파일을 따로 컴파일했기 때문입니다.

그러나 전체 모듈 최적화를 사용하면 컴파일러는 두 파일을 하나의 단위로 컴파일하고 Point 파일의 정의에 대한 통찰력을 갖게 되며 최적화가 이루어질 수 있습니다.

기본값으로 전체 모듈 최적화를 Xcode 8에서 지금 활성화했습니다. 이로 인해 최적화 기회가 크게 향상되었습니다.

다시 프로그램으로 돌아가 봅시다.

우리 프로그램에서 Drawable 프로토콜 타입 Pair을 가지고 있었습니다. 이것을 다시 사용해보겠습니다.

우리가 Pair을 만들고 싶을 떄마다 같은 타입의 Pair을 만들고 싶었습니다. 예를 들어 Line의 pair, Point의 paire

이렇게 되면 힙 할당이 두번 사용되죠

이 프로그램을 살펴보면서 우리는 여기에 제네릭 타입을 사용할 수 있다는 것을 깨달았습니다.

그래서, 우리가 우리의 쌍을 일반적으로 정의하고 그 일반적인 타입의 첫 번째와 두 번째 속성이 이 일반적인 타입을 가지고 있다면, 컴파일러는 실제로 우리가 같은 유형의 쌍만 만드는 것을 강제할 수 있습니다.

또한, 우리는 이후에 프로그램에서 라인의 쌍에 대한 포인트를 저장할 수 없습니다. 그래서, 이게 우리가 원했던 것이지만, 이것이 성능상으로 더 좋거나 나쁜 것인지 살펴보겠습니다.

그럼 우리는 이번에 쌍이 나왔습니다. 이번에는 store 속성이 제네릭 타입입니다.

이것이 생성된 코드에 대한 의미는 Swift가 둘러싸는 타입의 저장소를 인라인으로 할당할 수 있다는 것입니다. 따라서 두 개의 라인 쌍을 만들 때, 라인의 메모리는 실제로 둘러싸는 쌍의 인라인에 할당됩니다.

추가 힙 할당이 필요하지 않습니다. 정말 멋지네요.

하지만, 언급한 대로, 저장된 속성에 나중에 다른 유형의 값을 저장할 수는 없습니다. 그러나 이것이 우리가 원했던 것입니다.

우리는 값 증인과 프로토콜 증인 테이블을 사용하여 비특수 코드가 작동하는 방법을 보았으며, 컴파일러가 제네릭 함수의 유형별 버전을 만들어 코드를 전문화할 수 있다는 것을 보았습니다.

구조체를 포함한 전문화된 제네릭 코드의 성능을 살펴보겠습니다. 이 경우, 우리는 방금 본 것처럼 생성된 코드가 구조체를 사용하는 것과 동일한 성능 특성을 가지므로 이 함수를 구조체를 사용하여 작성한 것처럼 볼 수 있습니다. 구조체 유형의 값을 복사할 때 힙 할당이 필요하지 않습니다.
컴파일러 최적화를 더 활성화하고 런타임 - 실행 시간을 줄이는 정적 메소드 디스패치를 갖고 있습니다.

클래스 타입과 비교하면 클래스와 유사한 특성을 얻게 되므로 인스턴스 생성과 힙 할당, 값을 전달하기 위한 참조 계수, V-테이블을 통한 동적 디스패치가 필요합니다.

이제 작은 값이 포함된 일반화되지 않은 제너릭 코드를 살펴보겠습니다. 지역 변수에는 값 버퍼가 스택에 할당되므로 힙 할당이 필요하지 않습니다. 참조가 포함되지 않은 경우 참조 계수는 필요하지 않습니다.

만약 우리가 큰 값을 일반적인 코드에서 사용한다면, 우리는 힙 할당을 발생시키게 됩니다. 하지만 저는 이전에 그 기술을 보여드렸습니다. 즉, 우회로로 간접 저장소를 사용하는 것입니다. 만약 큰 값이 참조를 포함한다면, 다시 말해서 참조 계산이 필요하다면, 우리는 다이나믹 디스패치의 힘을 얻게 됩니다. 이것은 우리가 하나의 일반적인 구현체를 우리의 코드 전체에서 공유할 수 있음을 의미합니다.

오늘 이 토론에서 struct와 class의 성능 특성, 제네릭 코드의 작동 방식, 그리고 프로토콜 타입의 동작 방식을 확인해 보았습니다.

가장 적은 동적 런타임 유형 요구 사항을 가진 애플리케이션 엔티티에 대한 적합한 추상화를 선택하십시오.

이를 통해 정적 타입 검사를 활성화하여 컴파일러가 컴파일 타임에 프로그램이 올바른지 확인할 수 있습니다.

  • 컴파일러는 구조체와 열거형과 같은 값 타입을 사용하여 프로그램의 개체를 표현할 수 있다면 값을 보존하는 성질을 얻을 수 있습니다. 따라서 상태를 의도하지 않게 공유하는 일이 없으며 빠른 코드를 얻을 수 있습니다.
  • 객체 지향 프레임워크로 작업하거나 엔티티가 필요한 경우 클래스를 사용해야하는 경우, Kyle은 참조 계산 비용을 줄이는 몇 가지 기술을 보여주었습니다.
  • 프로그램의 일부분이 더 정적인 형태의 다형성을 사용하여 표현될 수 있다면, 제네릭 코드와 값 형식을 결합하여 구현을 공유하면서 매우 빠른 코드를 얻을 수 있습니다.
  • drawable 프로토콜 유형 예제에서와 같이 동적 다형성이 필요한 경우, 프로토콜 유형과 값 형식을 결합하여 클래스를 사용하는 것과 비교 가능한 빠른 코드를 얻을 수 있지만, 여전히 값 의미 체계 안에 머무를 수 있습니다
  • 프로토콜 유형이나 제네릭 유형 내에서 큰 값 복사로 인한 힙 할당 문제가 발생하면, 이를 해결하기 위해 복사 및 쓰기와 함께 간접 저장소를 사용하는 방법을 보여드렸습니다.

'iOS > WWDC' 카테고리의 다른 글

What's New in Swift (2020)  (0) 2023.06.30
WWDC16) Understanding Swift Performance - 1  (0) 2023.04.28