본문 바로가기

면접질문정리/Swift문법

[Swift] Swift성능 개선을 위한 요소 3가지(Understanding Swift Performance, WWDC 2018 )

UnderStanding Swift Performance

[목표 및 알아볼 주제들]

  • 오늘은 성능을 사용해 디자인 공간을 좁힐 수 있또록 권한을 주는것이 목표이다.

카일: 경험상 성능영향을 고려하면 더 관용적인 솔루션을 찾는데 도움이 되는경우가 많다. 따라서 우리는 주로 성능에 중점을 두며 모델링에대해 조금 만져볼것이다.

  • 오버헤드에 대한 멘탈모델을 심화하기위해 구조체와 클래스를 사용해 일부코드를 추적해볼것이다.
  • 그다음 정리하기위해 배운내용을 적용하는 방법을 살펴볼 것입니다.
  • 후반에는 프로토콜 지향프로그래밍의 성능을 평가할 것입니다.
  • 프로토콜 및 제네릭과같은 고급 Swift의 기능의 구현을 살펴보고 모델링 및 성능 영향을 더 잘 이해해볼 것이다.
  • 빠른면책조항: 우리는 Swift가 사용자를 대신해 컴파일하고 실행하는 메모리 현황과 생성된 코드 표현을 살펴볼것이다.

첫번째, 추상화를 구축하고 메커니즘을 선택할때 무엇을 고려해야할까 ??

  1. 내 인스턴스가 stack이냐 heap에 할당되냐
  2. 이 인스턴스를 전달할때 참조 계산 오버헤드가 얼마나 될까요 ?를 스스로 자문해야한다.
  3. 2번의 인스턴스에서 메서드를 호출하면 정적 또는 동적으로 디스패치되나요 ?

빠른 Swift코드를 작성해 활용하지않는 역동성과 런타임에 대한 비용을 지불하지 않아야합니다.

Allocation, 메모리 할당은 어떻게 이루어지는가

Stack

Swift는 사용자를 대신해서 자동으로 메모리르 관리해줍니다. ARC가 할당 해제를 자동으로 해주죠 그리고 해당 메모리르 중 일부는 스택에 할당합니다.

스택의 구조는 간단합니다. 스택의 끝으로 밀고 끝에서 튀어나오는 구조입니다. 즉, 메모리를 할당하면 스택의 끝에만 추가되고 제거가 되기때문에 스택을 구현하거나 스택끝에 포인터를 유지해 푸시 및 팝을 구현할 수 있습니다.

우리가 함수를 호출할때 --또는 --스택끝에 있는 포인터를 Stack Pointer라고 합니다
그리고 함수를 호출할때 공간을 만들기위해 스택 포인터를 약간 감소시켜 필요한 메모리를 할당할 수도 있습니다.

함수실행이 끝나면 이 함수를 호출하기 이전의 위치로 스택 포인터를 다시 증가하도록 하여 메모리할당해제를 간단하게 할 수 있습니다.

결론 => stack은 메모리할당 해제가 쉽고 빠르다

Heap

스택보다 동적이지만 효율성이 떨어지는 힙과 대조된다.
힙을 사용하면 스택이 동적수명으로 메모리르 할당할 수 없는 작업을 수행할 수 있다.

그러나 이를 위허스는 보다 고급스러운 데이터 구조가 필요하다. 따라서 힙에 메모리를 할당하려면 실제로 힙 데이터 구조를 검색하여 적절한 크기의 사용되지않은 블록을 찾아야합니다. 그런다음 작업이 끝나면 할당을 해제를 하기위해서는 해당 메모리를 적절한 위치에 다시 삽입해줘야합니다.

따라서 여기에는 스택에서 간단히 정수를 할당하는 작업보다 더많은 것이 관련되있습니다. 뿐만아니라 여러스레드가 동시에 힙에 메모리를 할당할 수 있으므로 잠금 또는 기타 동기화 메커니즘을사용해 무결성보호를 해야합니다.

이는 꽤나 큰비용입니다. 하지만 이에서 성능을 더 향상 시킬 수 있는 방법이 존재합니다.

Stack과 Heap의 메모리 할당

point1,2를 생성하고 point2에 point1을 할당하여 값을 변경해보는 테스트입니다.

흔히 알고있는 값의 복사가 일어나 둘의 인스턴스는 독립적인 객체입니다. 그래서 값이 서로에 영향을 주지않죠

하지만 클래스는 어떨까요 ?

스택과 똑같은 방식으로 인스턴스를 할당해주었는데 메모리를 head과 stack모두를 사용합니다.

스택과 똑같이 변수와 상수는 stack에 저장는데 이때 클래스는 heap을 가르키는 주소가 저장되는것이죠. 그래서 포인터 1,2가 heap을 가르키고 있는것같습니다.

즉, 참조를 하는 방식이죠

이렇듯 클래스는 힙할당을 필요로하기에 구조체보다 구성하는데 비용이 더 많이 들어갑니다.

클래스는 힙에 할당이되고 참조의 특성이 존재하기떄문에 ID 및 간접저장과 같은 몇가지 특성이 존재합니다. 그러나 추상황를 위해 이러한 특성이 불필요하다면 구조체를 사용하는것이 더 나을 것입니다.

구조체는 클래스처럼 의도하지않은 상태공유에 취약하지않다. 따라서 이를 적용하여 일부 Swift코드의 성능을 향상시키는 방법을 살펴보시죠

Problem of heap allocation

할당시 빈곳을 찾고 관리하는 것은 복잡한 과정
무엇보다 thread safe해야한다는 점이 가장 큰문제

  • lock등의 synchronization동작은 큰 성능저하요소
    반면 stack할당은
  • 단순히 스택포인터 변수값만 바꿔주는 정도

Stack 메모리에만 할당하게끔 Type Modeling하는 방법?

뷰 레이어에서 가져온내용의 예시입니다.

makeBalloon함수는 이미지를 생성하고 다른 구성또는 다른 풍선의 전체구성공간을 지원합니다.

위에서 중요한 핵심 내용은 Stack을 사용해서 모든 처리를 하고자 하려는 취지입니다.
그런데 cache에 이미지와 식별자를 저장하는데 사용되는 String의 식별자타입은 사실 class로 구현이되어있습니다. 그렇기때문에 makeBalloon메서드로 이미지를 생성할때마다 계속 캐시에 값을 저장하면서 heap의 메모리도 사용하게되는것이지요… 굳이 그렇게 할필요있을까요 ?

만일 let key에서 사용하는 값 3가지만 사용할것이라면 구조체로 관리할 수도 있지않을까요 ?

이제 makeBalloon 함수를 호출할때 캐시적중이 있는경우 구조체로 구성했기때문에 heap할당이 없어져 오버헤드가 없습니다. 고로 스택에 할당하여 훨씬 빠르고 안전한 것이죠.

언제 RC를 deinit해야 하나요?

swift가 힙의 모든 인스턴스에 대한 총 참조 수를 유지한다는 것입니다. 그리고 인스턴스 자체에 유지합니다.
참조를 추가하거나 참조를 제거하면 해당 참조 카운트가 증가하거나 감소합니다. 그 수가 0에 도달하면 Swift는 더이상 힙에서 이 인스턴스를 가리키는 사람이 아무도 없다는 것을 알고 해당 메모리를 할당해제하는 것이 안전합니다.

즉, 더이상 이 인스턴스를 가르킬 필요가 없는 상황을 이해하고 그때서야 할당해제를 하는것이 안전합니다.

그러하면 구조체는 어떨까요 ? 구조체는 참조카운팅이존재하지않죠! 포인트 구조체를 구성할때 힙 할당이 관련되지않습니다. 복사할때 관련된 힐할당이 존재하지않았습니다. 고로 참조와 관련이 없죠. 따라서 포인트 구조체에 대한 참조 카운팅 오베허드는 없습니다.

그런데… 복잡한 구조체가 존재합니다.
예시와 함께 살펴보시죠

문자열 타입의 Text와 UIFont 타입의 font프로퍼티가 존재합니다. 앞에서 들은 String처럼 실제로 힙에 문자의 내용을 저장합니다. 따라서 RC가 필요하죠. 그리고 글꼴은 class입니다. 얘도 RC가 필요하죠 메모리 표현을 살펴보면 도개의 참조가 발생하였습니다. 그런데 이렇게 구성된 구조체에 복사본을 만들면 어떻게될까요 ??

실제로 두개의 참조가 더 추가됩니다. 하나는 텍스트 저장소에 다른 하나는 글꼴에대한 참조로 저장이되죠.

따라서 여기서 레이블은 실제로 클래스가 가질 수 있는 RC 오버헤드의 두배를 발생시킨다는것을 알 수 있습니다.

따라서 클래스가 힙에 할당되기에 Swift는 heap할당의 수명을 관리해야하 합니다. RC과 함께 수행이되죠. RC가 비교적 자주발생하고 Rc의 원자성때문에 기는 크게 중요하지는 않습니다만… 구조체에 참조가 포함되면 참조계산 오버헤드가 발생하게됩니다.

구조체는 포함된 참조수에 비례하여 참조계산 오버헤드를 지불하게됩니다. 따라서 참조가 두개이상인 경우 클래스보다 참조계산 오버헤드가 더많이 유지되는것이죠

RC의 문제?

[사진첨부: 클래스와 구조체와 참조를 포함한 구조체의 메모리 성능 그래프]

class

struct

struct containing reference

이론적인 메세징 응용프로그램에서 가져온 다른예 이것을 어떻게 적용하는지 봐봅시다.

사용자들이 단순히 문자메세지를 보내는데 만족하지못해 서로 이미지와 같은 첨부파일을 보내고 싶어한다고 가정을한 예시입니다. 그래서 모델개체인 구조체내에 첨부파일 프로퍼티가 존재하죠

디스크의 내 첨부파일을 저장하는 데이터경로 fileURL이 있고, 무작위로 고유식별자를 생성해 클라이언트와 서버에서 첨부파일을 인식할 수 있게하는 uuid, 그리고 JPG, PNG또는 GIF와 같이 첨부파일이 나타내는 데이터 유형을 저장하는 mimeType이 존재합니다.

이 초기화 프로그램은 모든 MimeType중 하나인지 확인합니다. 지원되지않는 경우 작업을 중단하죠. 그렇지않으면 fileURL, uuid 및 mimeType을 초기화합니다. 그래서 많은 RC의 오버헤드를 발견했으며 이 구조체의 메모리표현을 실제로 보면 이러한 각 구조체의 기본 heap할당에대한 참조가 존재하기때문에 3가지모두 RC 오버헤드를 발생시킵니다.

우리는 앞서 살펴보았듯 잘 개선할 수 있었습니다. 한번 개선해보죠!!

먼저 uuid는 정말 잘 정의된개념입니다. 128bit의 무작위로 생성된 식별자입니다.
우리는 UUID필드에 아무것도 입력할 수 없도록 하고 있습니다. 왜냐하면 Foundation에서 2018년도에 uuid라는 새로운 유형을 추가하였고 이는 128bit를 구조체에서 직접 저장하기때문입니다. 즉, String처럼 heap을사용하지 않는다는 것이죠!
너무 환상적이지않나요 ???

그리고 고정된 집합을 표현할 수 있는 enum을 활용해 mimeType을 더많은 유형으로부터 안전하게 매핑했습니다.

결국 힙에 간접적으로 저장할 필요가없어 성능도 높아졌습니다.

실제로 swift는 rawVAlue String값으로 뒷받침되는 enum을 사용하는 이정확한 코드를 작성하기위한 매우 간결하고 편리한 방법을 가지고 있습니다.

개선을 한 후에는 메모리 현황이 이렇게 됩니다.

method Dispatch

메서드 디스패치란 무엇인가?

static Method Dispatch

컴파일 시점에 컴파일러가 메소드의 실제코드 위치를 파악할 수 있어 런타임에 찾는 과정없이 바로 해당코드를 실행하는 것을 의미한다.
=> 컴파일 시점에 메소드의 실제코드위치를 안다면 실행중 찾는 과정없이 바로 해당코드 주소로 점프할 수 있음 -> 메소드 인라이닝 최적화가 가능해진다.

인라인이란?
메소드를 호출할때 해당 메소드로 이동하지않고 메소드의 결과값을 바로 반환하여성능을 향상시키는 것이다.
=> 컴파일 시점에 메소드 호출부분에 메소드 내용을 붙여넣음

Dynamic Method Dispatch

컴파일 타임에 어떤 메소드를 호출하는지 판단할 수 없어, 런타임에 table에 구현을 참조해 해당 메소드에 대한 정보를 가져와서 코드를 실행시키는 것을 의미한다.

사실 dynamic dispatch는 static dispatch보다 그렇게 많은 비용을 필요로 하지않습니다. 레퍼런스 카운팅, 힙 할당과 같은 쓰레드 동기 오버헤드가 없습니다.

하지만 컴파일러는 static dispatch 에서는 최적화 작업이 가능하지만 Dunamic dispatch에서는 컴파일러가 추론할 수 없습니다.

여기서 우리는 두개의 static dispatch을 처리하는 ㅘ정에서 별다른 오버헤드가 발생하지않는것을 확인했습니다.

메서드 인라이닝 덕에 런타임에는 메서드 호출에서 다른 추가작업이 필요없기때문입니다.

단일 static dispatch와 dynamic dispatch의 차이는 크지않습니다. 하지만 여러개의 method dispatch가 발생하는 disaptch chain에서는 차이가 있습니다.
static dispatch chain에서는 컴파일러가 모두 파악할 수 있는 반면에, dynamic dispatch chain에서는 컴파일러가 추론할 수 없습니다.

컴파일러는 static method dispatch chain을 메소드 인라이닝으로 붕괴시켜서 콜 스택 오버헤드 없이 단일 구현형태 즉, 하나의 코드 덩어리로 바꿀 수 있습니다. 이를 통해 우리는 static dispatch를 통해 빠른 처리를 할 수 있다는 것을 알 수 있습니다.

예시로 위와같은 drawAPoint메소드를 외부에서 호출한다고 가정을하고 코드를 메소드인라이닝하는 횟수를 살펴봅시다.

처음에 param.draw메소드를 호출하기위해 param의 타입인 Point타입을 가져옵니다. 인라이닝 + 1

그리고 param.draw의 메서드 호눌 내부의 구현부를 복사해서 가져옵니다.
인라이닝 + 1 = 2

Dynamic dispatch in class

그렇다면 왜 다이나믹 디스패치가 필요한가요 ?

가장 큰이유는 다형성 때문입니다.

전통적인 객체지향 프로그램의 예시를 살펴보도록 하겠습니다.

Drawable 클래스를 상속받은Point, line 클래스들을 배열 타입으로 인스턴스를 생성하여 반복문으로 draw메서드를 호출하는 상황입니다.

그런데 컴파일타임에 저 반복문의 메소드를 정확한 구현부로 대체할 수 있을까요 ??

static method dispatch 의 메소드 인라이닝과같이 해당 메소드의 body구현부를 대체하는것은 불가능해보입니다…

d.draw()는 point가될지, line이 될지 모르기때문이죠.

이를 해결하기위해 컴파일러는 클래스에 필드를 하나 추가합니다.

클래스는 타입정보에 대한 포인터이며 타입정보는 static memory에 저장되있습니다.

이제 draw메소드를 호출할때, 컴파일러는 우리를 대신해 static memory에 있는 타입정보를 보고 실행되어야 할 구현부를 가리키는 포인터가 있는 virtial method table를 통해 메소드를 호출합니다. 이 table은 V-table이라고도 불립니다.

이제 draw메소드를 호출할때 컴파일러는 static memory에 있는 타입정보를 보고 실행되어야할 구현부를 카리키고 있는 포인터가 있는 V-table를 통해 메소드를 호출합니다.

그래서 컴파일러가 어떻게 d.draw()를 타입에 맞게 메서드를 호출하는지 확인해보면 virtual method table을 통해 실행ㅎ에 올바른 draw구현부를 찾는 것을 볼 수있습니다.

그리고 실제 인스턴스를 암묵적 self-parameter로 넘겨줍니다. 즉, d를 파라미터로 넘겨준것이죠.

Dynamic Method Dispatch의 문제

  • 실제 Type을 컴파일 시점에 알수가없다는것 떄문에 코드 주소를 runtime에 찾아야한다.

Static Dispatch로 강제하기!!

  • final private등을 쓰는 버릇을 들이자.

해당 메소드, 프로퍼티등은 상속되지않으므로 static하게 처리할 수 있기떄문

  • dynamic쓰지 말자.
  • Objec연동을 최소화화자.

이떄는 컴파일이아니라 런타임에 코드 최적화를 진행하기때문이다.

  • WMO(whole module optimization)을 작동시키자.

이는 빌드시에 모든파일을 한번에 분석해 static dispatch로 변환가능한지 판단하여 최적화합니다. 이는 final을 붙여주지않아도 static하게 해준다.

설정하게되면 겁나느려진다고합니다.

static에 비해 단지 이것이문제. Thread Safety문제도 없다. 하지만 이로인해 컴파일러가 최적화를 못하는것이 큰문제라는 것이다.

정리

class

클래서의 성능은 아래와 같습니다.
heap allocation을 사용하고 이로인해 RC가 발생하죠. 또한 dynamic dispatch메소드를 호출하게됩니다. 하나의 메소드만 봤을때, Dynamic dispatch는 static dispatch와 큰차이는 없지만 메소드체인과 같은 여러메소드가 얽혀있는 상황에서는 메소드 인라이닝과같은 최적화가 불가능합니다.

final Class

서브클래스를 만들지않는다면 final을 선언하여 컴파일러가 static하게 dispatch하게할 수 있습니다. 서브클래싱을 하지않겠다고 공표허가애 코드를 읽는 팀원들에게도 서브클래싱을 하지않는다는 명확성을 전달할 수 있쬬.

struct

Reference