iOS/WWDC

WWDC16) Understanding Swift Performance - 1

Brad_Heo 2023. 4. 28. 20:49

주제: 디자인 공간을 좁히고 작업에 적합한 올바른 도구를 선택하는 방법

  1. Swift의 다른 추상화 메커니즘의 모델링 영향을 고려
  2. 값 또는 참조 세마틱스 중 어떤 것이 적합한지
  3. 이 추상화를 얼마나 동적으로 만들어야 할지

메모리 할당

빠른 Swift 코드를 작성하려면 우리는 활용하지 않는 동적성과 런타임에 대한 가격을 피해야 합니다.

Swift는 자동으로 메모리를 할당하고 해제 합니다. 그 중 일부는 스택에 할당 됩니다.

Stack

스택은 매우 간단한 데이터 구조 입니다. 스택의 끝에 push할 수 있고, 스택의 끝에서 pop할 수 있습니다.
스택은 항상 끝에서 추가 및 제거가 일어나기 떄문에 끝점을 가리키는 포인터만 유지하면 됩니다.
스택 포인터라고 불리는 스택의 끝점에 메모리를 할당 할 수 있음을 의미 합니다.
함수 실행이 마치면, 스택 포인터를 증가시켜 할당된 메모리를 해제할 수 있습니다.

스택포인터
중앙처리 장치 안에는 스택에 데이터가 채워진 위치를 가리키는 레지스터인 스택 포인터(SP)를 갖고 있다. 스택포인터가 가리키는 곳까지가 데이터가 채워진 영역이고, 그 이후부터 스택 끝까지는 비어있는 영역이다. 스택에 새로운 항목이 추가되거나 스택에서 데이터가 제거되면, 스택 포인터의 값이 증가하거나 감소한다.

heap

힙은 동적 수명을 가진 메모리를 할당하는 것과 같은 스택에서 할 수 없는 작업을 수행할 수 있습니다.
하지만 더 고급 데이터 구조를 필요로 합니다.

  1. 힙에서 메모리를 할당하려면 사용되지 않은 메모리 블록을 찾기 위해 힙 데이터 구조를 검색
  2. 사용한 후에는 해당 메모리를 다시 적절한 위치에 재삽입하여 할당 해제해야 함

또한 스택에서 할당하는 것과 같이 정수를 할당하는 것 이상의 작업이 필요로 합니다.

-> 여러 스레드가 동시에 힙에 메모리를 할당할 수 있기 때문에 힙은 잠금 똔는 기타 동기화 메커니즘을 사용하여 무결성을 보호해야 하기 때문입니다.

프로그램에서 언제 어디서 힙에 메모리를 할당하는지 주의하지 않으면 성능을 크게 개선할 수 있습니다.

Example1: Point 구조체

point1와 point2는 독립적인 인스턴스 입니다.
왜냐하면 point1을 point2에 할당할 때, 단지 point를 복사본을 만들고 이미 스택 상에 point2에 메모리를 초기화하기 때문입니다.
즉 pint2.x에 5의 값을 할당해도 point1.x값은 여전히 0입니다.

구조체 대신 클래스를 사용하는 코드와 비교

이전과 마찬가지로 함수에 들어가게 되면 스택에 메모리를 할당합니다. 그러나 포인트의 실제 속성을 저장하는 것이 아니라, point1, point2에 대한 참조를 위한 메모리를 할당합니다.

메모리 참조는 힙(heap)에 할당될 것 입니다. 처음 (0,0)에서 점을 구성할 때 Swift는 힙을 잠그고, 해당 데이터 구조에서 적절한 크기의 사용되지 않은 메모리 블록을 검색합니다. 그런 다음, 우리가 그것을 가지고 있으면, 그 메모리에 x=0, y=0인 값을 초기화할 수 있으며, point1 참조를 힙상의 해당 메모리 주소로 초기화할 수 있습니다.
참고로 힙에 할당할때, Swift는 실제로 point에 대해 4개의 워드(word) 저장 공간을 할당합니다.
이는 구조체였을 때 할당된 2개의 워드와 대조적입니다. 이제 point가 클래스이기 때문에 x와 y에 저장된 것에도 Swift가 우리 대신 할당된 2개의 워드와 대조적입니다. 이제 point가 클래스이기 때문에 x, y가 저장된 이외에도 Swift가 우리 대신 관리할 두 개의 워드를 할당하기 때문입니다. 이것은 위의 사진의 파란색 상자로 표시됩니다.

point1을 point2에 할당할 때, point1이 구조체인 것처럼 내용을 복사하지 않습니다. 대신 참조를 합니다. 실제로 point1, point2 동일한 인스턴스를 참조합니다. 이것은 참조 의미론이라고 하여 의도하지 않은 상태 공유로 이어질 수 있습니다. 그런 다음 point1, point2을 사용하고, Swift는 우리를 대신하여 메모리를 해제하고 힙을 잠금 상태로 유지하며 사용하지 않은 블록을 적절한 위치로 유지합니다. 그런 다음 스택에 pop할 수 있습니다.

결론

클래스는 구조체보다 더 비싼 자원을 쓸 수 있습니다. 왜냐하면 클래스는 힙의 할당이 필요로 하기 떄문입니다.

클래스는 힙에 할당되고 참조에 의한 의미론적 특징을 가집니다. 장점으로는 식별성과 간접 저장소 같은 강력한 특징이 있지만 추상화에 이러한 특징이 필요하지 않다면 구조체를 사용하는 것이 좋습니다.

Swift코드의 성능 향상 시키는 방법

여기 텍스트 뒤에 풍선 이미지를 그리는 예제가 있습니다.

makeBallon 함수는 사용자 스크롤 중 자주 호출이 되므로 빠르게 실행 되어야 합니다. 그래서 캐싱 레이어를 추가 했습니다.
그래서 특정 구성에 대해서는 풍선 이미지를 한 번 이상 생성할 필요가 없습니다. 한번 생성했다면 캐시에서 가져올 수 있겠죠.
이것을 수행하는 방법으로는 색상, 방향 및 꼬리를 직렬화하여 문자열로 된 키로 만드는 것입니다.

문제점

문자열은 키에 대해서 강력한 타입이 아닌 것이죠, 예를 들어 나의 개의 이름을 그 키에 넣을 수 있어 안정성이 크게 보장되지 않습니다. 또한 문자열은 실제로 그 내용을 힙에 저장되기 때문에 많은 것을 나타낼 수 있습니다. 그래서 이 makeBallon 함수를 호출할 때마다 캐시가 있더라도 힙 할당이 발생됩니다.

더 나은 방법

Swift에서 색상, 방향 및 꼬리의 구성을 구조체를 사용하여 나타낼 수 있습니다. 이는 문자열보다 이 구성 공간을 더 안전하게 나타내는 방법입니다. 또한 구조체는 일급 객체이기 떄문에 딕셔너리의 키로도 사용될 수 있습니다.

이제 makeBalloon 함수가 호출할 때 캐시 히트가 발생하게 되면, 이러한 속성을 가진 구조체를 구성하는데 힙 할당이 아니라 스택에 저장되기 떄문에 할당 오버헤드가 없습니다. 따라서 이 방법은 훨씬 안전하며 빠릅니다.

참조 계수(Reference Counting)

앞 서 힙 할당 세부사항을 누락했습니다. Swift가 힙에 할당된 메모리를 안전하게 해제할 수 있는 시점을 어떻게 알까요? 답은 Swift가 힙의 인스턴스에 대한 참조 총 수를 계산하여 유지한다는 것 입니다. 그리고 그 카운트는 인스턴스 자체에 저장됩니다. 참조를 추가하거나, 제거할 떄마다 참조 수가 증가, 감소 합니다. 그 카운트가 0이 되면 Swift는 더 이 상 인스턴스를 가리키는 참조가 없다는 것을 알고 이 메모리를 안전하게 해제 합니다.

참조 카운팅에서 기억해야할 중요한 점은 이것이 매우 빈번한 작업이면 실제로 정수를 증가, 감소시키는 것 이상의 작업을 합니다. 먼저 단순히 증가, 감소를 실행하기 위해 간접 참조 수준이 필요합니다. 그러나 더 중요한 것은 힙 할당과 마찬가지로 참조가 동시에 여러 스레드에서 발생할 수 있으므로 참조 회수를 원자적으로 증가, 감소를 시켜야 하는거죠. 이러한 작업 빈도 떄문에 비용이 누적될 수 있다는 것도 참고해야합니다.

다시 point 클래스와 프로그램으로 돌아와서 Swift가 실제로 우리를 대신해 무엇을 대신하고 있는지 보겠습니다.

여기 몇가지 추가된 코드가 있습니다.

retain, release이죠

retain은 우리의 참조 횟수를 원자적으로 증가시키고, release는 원시적으로 참조 횟수를 감소시킵니다.
이러한 방식으로 Swift는 힙에 대한 우리의 point에 대해 살아있는 참조가 몇 개 인지를 추적할 수 있습니다.

아래 그림은 순서대로 할당되고 감소되는 과정을 볼 수 있습니다.

마지막으로 더 이상 point인스턴스를 사용하는 참조가 없으므로 Swift는 힙을 잠그고 해당 메모리 블록을 반환 합니다.

다시 구조체로 돌아와 봅시다.

구조체에는 참조 계산이 필요하나요? 우리가 point 구조체를 구성할 때 힙 할당이 없었습니다. 복상할떄에도 없었죠. 이 모든 것에 참조가 포함되어 있지 않습니다. 따라서 point 구조체에는 참조 계산 오버헤드가 없었습니다. 그렇다면 더 복잡한 구조체라면 어떨까요?

여기 String타입의 텍스트와 UIFont타입의 폰트를 포함하는 Label 구조체가 있습니다.

앞 서 설명했듯이 String은 힙에 저장합니다. 따라서 참조 계산이 필요하죠. 그리고 font는 클래스 입니다. 마찬가지로 참조 계산이 필요하죠.

메모리 표현을 살펴 봅시다.

label에는 두 개의 참조가 있습니다. 그리고 복사본을 만들 떄 실제로 텍스트 저장소와 폰트에 두 가지 참조를 추가합니다.

Swift가 힙 할당을 추적하는 방법은 retain과 release호출을 추가하는 것 입니다.

다시 위의 그림을 살펴 봅시다.

여기에 라벨이 클래스보다 2배의 참조 계산 오버헤드를 발생시키고 있다는 것을 볼 수 있습니다.
요약하자면 클래스는 힙에 할당되므로 Swift는 해당 힙 할당의 수명을 관리해야하고 참조 계산으로 수행해야 합니다. 이 작업은 자주 발생하며 참조 계산의 원자성 떄문에 복잡합니다.

이것은 구조체를 사용하는 또 다른 이유죠.

하지만 구조체에 참조가 포함되어 있으면 참조 계수 오버헤드를 지불해야 합니다. 다시말해 구조체는 포함된 참조 수에 비례하여 참조 계수 오버헤드를 지불하게 됩니다. 따라서 참조가 하나 이상이면 클래스보다 더 많은 참조 계수 오버헤드를 유지하게 됩니다.

다시 다른 예제를 보겠습니다.

사용자들은 문자만 보내지 않고 이미지와 같은 첨부 파일을 보내고 싶습니다. 그래서 애플리케이션에 모델 객체 구조체 첨부 파일을 추가했습니다. 이 구조체에는 첨부 파일의 데이터 경로를 저장하는 filreURL 속성이 있습니다. 또한 클라이언트 및 서버에 다른 장치인 것을 인식하게 위해 식별자인 uuid가 있습니다. 이 구조체에는 JPG, PNG, GIF와 같은 데이터 유형을 나타내는 mimeType이 있습니다.
이 응용 프로그램에서 지원하는 minmeType 중 하나인지 확인하는 실패 가능한 이니셜 라이저가 거의 유일한 비 자명한 코드 입니다. 모든 mimeTypes를 지원하지 않기떄문에 지원되지 않으면 이를 중단 합니다. 그렇지 않을 경우 URL, uuid 및 mimeType을 초기화 합니다. 이 구조체에 메모리 표현을 실제로 살펴보면 각 구조체의 하위에 힙 할당에 대한 참조가 있기 떄문에 3개의 속성 모두 참조 계수 오버 헤드가 발생합니다.

이제 한번 더 좋은 방법으로 수정해볼까요

첫번째로 uuid는 정말 잘 정의된 개념입니다. 이는 128비트로 생성된 무작위 식별자 입니다. 우리는 uuid 필드에 아무 것이나 넣을 수 없도록 하고 싶고 String은 실제로 그렇게 할 수 있습니다.
하지만 올해 Foundataion은 새로운 값 타입을 추가했습니다.
uuid를 위한 것인데 이는 구조체 내에서 그 128비트를 직접 저장하기 때문에 정말 좋습니다. 그렇게 되면 참조 계산 오버헤드를 제거할 수 있습니다. uuid필드에서 또한 이 필드는 uuid만 넣을 수 있기 떄문에 더욱 안전해집니다.

자 이제 mimType을 살펴보고 이 isMimeType체크를 어떻게 구현했는지 살펴봅시다.

실제로 JPG, PNG, GIP라는 닫힌 mimeTypes 집합만 지원합니다.

자 먼저 열거형에 대해 알아 봅시다.

열거형은 Swift의 고정된 것들의 집합을 나타내기 위한 휼륭한 추상화 메커니즘 입니다. 저는 그 스위치 문을 실패 가능한 이니셜라이저 내부에 넣고 그 MimeTypes를 내 열거형의 적절한 케이스로 매핑할 것 입니다.

그러면 이제 mimeType 열거형으로 더 많은 타입 안전성을 가지게 되었고, 서로 다른 케이스를 간접적으로 힙에 저장할 필요가 없으므로 더 많은 성능을 얻을 수 있습니다. 열거형은 바로 값타입이기 떄문이죠.
실제로 Swift는 이 정확한 코드를 작성하기 위한 매우 간결하고 편리한 방법을 갖고 있으면 문자열 값을 가진 열거형을 백업으로 사용합니다.
따러서 이것은 실제로 정확히 같은 코드이지만 더 강력하면 동일한 성능 특성을 가지고 있지만 훨씬 편리하게 작성할 수 있습니다.
자 이제 다시 구조체를 보면 이제 더 많은 타입 안전성을 가지고 있게되었습니다. 우리는 타입의 uuid와 mimeType필드를 가지고 있으며, uuid와 MimeType은 참조 계산 오버헤드를 지불할 필요가 없으므로 힙에 할당할 필요가 없어졌습니다.

메소드 디스패치

런타임에서 메소를 호출할 떄 Swift는 올바른 구현을 실행해야합니다.

컴파일 시간에 실행할 구현을 결정할 수 있다면, 이를 정적 디스패치라고 합니다.
그리고 런타임에서는 올바른 구현으로 직접 이동할 수 있습니다. 이것은 실제로 컴파일러가 실행될 구현을 볼 수 있으므로 멋집니다. 그래서 인라인과 같은 것들을 포함하여 이 코드를 적극적으로 최적화할 수 있습니다.

이 정적 디스패치는. 동적 디스패치와 대조적입니다.

동적 디스패치는 직접 컴파일 타임에서 어떤 구현을 취해야하는지 알 수 없습니다. 그래서 런타임에서 구현을 찾고 이동을 하죠. 그렇기 떄문에 정적 디스패치보다 비용이 크지 않습니다. 단지 간접 참조가 하나 있을 분 입니다. 참조 계산, 힙 할당과 같은 스레드 동기화 오버헤드도 없죠.
그러나 큰 단점이 하나 존재합니다. 바로 컴파일러의 가시성을 차단하므로 컴파일러는 정적 디스패치에서 최적화를 수행할 수 있지만 동적 디스패치인 경우 할 수 가 없습니다. 컴파일러는 동적 디스패치를 이해를 못하거든요. 그래서 인라인에 대해 언급을 했습니다.

인라인에 대해 알아보죠

다시 익숙한 구조체로 돌아가 봅시다.

여기에 drawAPoint 메서드를 추가했습니다. drawAPoint 메서드는 점을 가져와 그림을 호출합니다. 좋습니다.

drawAPoint함수와 point.draw메서드느 모두 정적 디스패치가 됩니다. 왜냐하면 컴파일러가 실행될 구현을 정확히 알기 떄문이죠.

실행 시 아래 그림처럼 drawAPoint 디스패치를 가져와서 drawAPoint의 구현으로 대체할 것입니다.

그런다음 point.draw메서드를 가져와서 실제 구현으로 대체할 수 있습니다. 정적 디스패치이기 때문이죠. 그래서 런타임에서 코드를 실행할 떄 그저 Point를 만들고 구현을 실행하고 끝나게 됩니다. 저희는 따로 오버헤드와 콜 스택의 설정 및 해제에 대해 관련된 작업을 하지 않았습니다. 이것이 정적 디스패치가 빠르며 어떻게 빠른지에 대한 것입니다.

자 이제 이렇게 정적 디스패치가 좋은데 왜 동적 디스패치가 필요한지에 대해 알아 봅시다.

이유 중 하는 바로 다형성과 같은 매우 강력한 기능을 가능하게 합니다. 객체 지향 프로그래밍을 살펴보면 그리기와 관련된 기본 구현을 재정의하는 포인트 서브 클래스와 라인 서브클래스를 정의할 수 있습ㄴ디ㅏ. 그리고 다형적으로 배열을 만들어 그리기 가능한 배열을 만들 수 있습니다.
이 배열에는 라인이나 포인트가 포함될 수 있으며, 각각에 대해 draw를 호출할 수 있습니다.

동작원리에대해 알아봅시다.

drawable, point 그리고 line 모두 클래스이기 때문에 배열을 만들 수 있습니다. 배열은 참조로 저장되기 떄문에 모두 같은 크기를 가지게 되면 이것들을 통과할 떄마다 draw를 호출하게 됩니다.

그래서 이 컴파일러가 왜 어떤 구현을 실행할지 컴파일 시간에 결정할 수 없는지 알아 봅시다.

바로 이 d.draw가 점, 선 그리고 면일수도 있습니다. 확실하지 않다는 거죠

이것들은 다른 코드 경로라서 어떻게 이것이 어떤 것을 호출할지 결정할까요??

컴파일러는 클래스에 다른 필드를 추가해 이 클래스의 타입 정보를 가리키는 포인터를 만드빈다. 이 포인터는 정적 메모리에 저장되고 우리가 draw를 호출하면 가상 메소드 테이블대해 조회 합니다. 가상 메소드 테이블은 올바른 구현을 실행하기 위한 포인터를 포함하고 있습니다.
만약 우리가 d.draw를 컴파일러 대신해주는 것으로 변경한다면, 실제로 가상 메소드 테이블을 통해 올바른 draw 구현을 찾아 실행하게 됩니다.
그리고 인스턴를 암시적인 self-매개변수로 전달합니다.

자 여기서 정리를 해봅시다.

기본적으로 클래스는 동적 디스패치로 디스패치됩니다. 이것만으로 큰 차이가 없지만 메소드 체이닝 등에서 최적화를 방해할 수 있고 그것은 누적될 수 있습니다. 그러나 모든 클래스가 동적 디스패치로 되지않습니다.

서브 클래스를 만들 계획이 없다면 final키워드를 표시해 그것을 의도된것을 알려줄 수 있죠, 컴파일러는 이를 인식해 메소드를 정적으로 디스패치 할 것 입니다. 애플리케이션에서 클래스를 서브 클래스로 만들 계획이 없다는 것을 추론하고 증명할 수 있다면 동적 디스패치를 정적 디스패치로 자동 전환하게 될 것 입니다.

요약해봅시다.

스택 또는 힙에 인스턴스가 할당되고 얼마나 많이 참조가 될 것인가, 메소드를 호출할 떄 정적으로 처리될 것인가, 동적으로 처리될 것인가? 불필요한 동적 처리 비용을 지불한다면 선느에 악영향을 미칠 것 입니다.

그리고 당신이 Swift를 처음 사용하거나 Objective-C에서 Swift로 이전된 코드 베이스에서 작업하고 있다면, 당신은 현재보다 더 많은 장점을 얻을 수 있습니다. 여기에서 본 것처럼, 문자열 대신 구조체를 사용하는 이유가 있습니다.

자 이제 "구조체를 사용하여 다형성 코드를 작성하는 방법"에 대해 알아 봅시다.

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

What's New in Swift (2020)  (0) 2023.06.30
WWDC16) Understanding Swift Performance - 2  (0) 2023.05.02