Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Understanding Swift Performance(1/2) #14

Open
kimscastle opened this issue May 4, 2023 · 0 comments
Open

Understanding Swift Performance(1/2) #14

kimscastle opened this issue May 4, 2023 · 0 comments
Assignees

Comments

@kimscastle
Copy link
Contributor

개요

image

추상화를 building 하고, abstraction mechanism 을 선택하기 위해, 우리는 위와 같이 세 가지 dimension 들을 고려해 봐야 한다. 이 글에선 편의를 위해 구조체 를 구조체로, class 를 클래스로 작성했다.

  • 인스턴스가 Stack 에 할당되야 할까? Heap 에 할당되야 할까?
  • 인스턴스를 전달할 때 얼마나 많은 참조 카운팅 오버헤드가 발생할까? (참조 카운팅은 간단해 보이지만, atomicity 하게 작동해야 하기 때문에 단순한 연산과는 다르다)
  • 인스턴스의 메소드를 호출할 때, static 하게 호출되야 할까 dynamic 하게 호출되야 할까?

Allocation

Stack 메모리 영역

  • Stack 은 매우 간단한 자료구조다. LIFO 형태의 자료구조로, stack pointer 를 단순히 옮기는 것 만으로 새로운 메모리를 할당하거나 메모리를 회수할 수 있다. Stack 영역에 새로운 메모리를 할당하는 것은 단순히 Integer 를 할당하는 것만큼 빠르다.

Heap 메모리 영역

  • Heap 은 Stack 에 비해 복잡한 자료구조로, Stack 에 비해서 성능이 좋지 않다. 그러나 stack 과는 달리 특정 lifetime 에 종속적이지 않고, 인스턴스가 dynamic 한 lifetime 을 가지게 해주는 메모리 영역이다.
  • Heap 영역에 새로운 메모리 영역을 할당하려면 다음과 같은 단계를 거친다.
      1. Heap 안에서 사용하지 않는 적절한 크기의 블록을 찾는다.
      1. 해당 메모리 영역의 사용이 끝나면, 다시 적절한 위치로 메모리를 deallocate 한다.

Heap 과 Stack 메모리 영역의 차이

모든 Thread 는 독립적인 Stack 영역을 가진다. 이 말은 즉 Stack 메모리 영역에 할당되는 인스턴스들은 Thread - safe 하다는 뜻이다. 그러나 Heap 은 Thread 에 종속적이지 않고 global 하게 관리되기 때문에, Multi thread 에 의해 접근될 수 있다. 이 말은 즉 Heap 영역은 locking mechanism 이나 syncronization mechanism 을 사용해 관리해야 한다는 뜻이다.

locking 이나 synchronization 은 매우 큰 비용이 드는 작업이기 때문에, Heap 메모리 영역은 Stack 메모리 영역에 비해 많은 성능을 요구한다.

Stack 영역와 Heap 영역에 대해 더 알아보기

Stack 영역에 대해 이해하기 위해 아래 예제를 살펴보자.

image

  • 위 코드에서 point1point2 인스턴스는 Stack 메모리 영역에 할당된다. 또한 두 변수는 아예 다른 인스턴스를 가지고 있다 (Value - semantics)
  • point1 과 point2의 사용이 끝나면, 단순히 Stack pointer 를 decrement 하는 것으로 메모리를 deallocate 할 수 있다.

이번엔 Heap 영역에 대해 알아보기 위해 구조체 대신 클래스 를 사용해 볼 것이다.

image

  • 클래스 는 참조 타입이기 때문에, 인스턴스에 대한 메모리를 stack 에 할당하던 값 타입과 달리, Heap 영역에 존재하는 실제 인스턴스 주소값을 참조하기 위한 메모리 공간이 stack 에 할당된다.
  • Heap 영역은 모든 쓰레드에 의해 접근될 수 있기 때문에, 메모리를 할당할 때 동기화 기법 혹은 lock 기법을 사용해 빈 메모리 영역을 검색한다.
  • 또한 변수에 할당하면 인스턴스가 복사되던 값 타입과 달리, 두 개의 지역상수는 모두 똑같은 메모리 주소를 참조하는 것을 볼 수 있다.
  • 구조체 와 달리 파란색 박스가 2개 더 추가된 것을 볼 수 있다. 여기에 swift 가 클래스 타입을 다루는데 필요한 정보들이 들어간다.
  • 클래스 인스턴스의 사용이 끝나면 Heap 메모리가 다시 반환된다.

보는 것과 같이 Stack 메모리 영역은 메모리 할당과 반환 작업이 매우 빠르다. 또한 Stack 영역은 단일 쓰레드에 하나 밖에 존재하지 않으므로 lock 혹은 동기화 기법을 사용할 필요가 없다.
그러나 Heap 영역은 global 하게 관리되는 메모리 영역이고 모든 쓰레드가 접근할 수 있으므로, Stack 영역에 비해 메모리 할당과 반환의 성능이 매우 느리다.

예제를 통해 Allocation 성능 차이를 이해해보자.

image

위 예제에서 enum 을 통해 말풍선의 색상, 방향, 그리고 말꼬리 모양을 관리한다. 그리고 위 enum 들을 받아서 적절한 UIImage 인스턴스를 반환하는 makeBalloon 메소드가 있다.

채팅 앱을 사용할 때 스크롤 할 때마다 지난 메시지들이 불러와지면서 말풍선이 계속해서 생기는 것을 볼 수 있다. 따라서 makeBalloon 메소드는 매우 많이 호출될 것이고, UIImage 는 생성이 매우 비싼 객체이기 때문에, 다음과 같이 Cache 를 사용해 성능을 최적화 했다.

image

Cache 타입을 살펴보면, key 타입으로 String 을 사용하고, color와 orientation, 그리고 tail enum 을 사용해 String key 값을 생성하는 것을 볼 수 있다.

위 코드엔 다음과 같은 단점들이 존재한다.

  1. String 은 Dictionary 의 key 타입으로 들어가기 부적절하다. 우리 집 개 이름과 같이 전혀 관계없는 값도 들어갈 수 있기 때문에 type safe 하지 못하다.
  2. String 은 자신이 갖고 있는 character 들을 heap 에 저장한다. 따라서 makeBalloon 메소드가 호출될 때마다, String key 값을 생성하기 때문에 heap allocation 이 일어난다.

위 예제를 어떻게 개선할 수 있을까?

Cache 의 키 타입을 String 으로 하는 대신 말풍선의 속성을 나타내는 구조체를 새로 정의해 cache 의 키 타입으로 사용해보자.

구조체 Attributes {
  var color: Color
  var orientation: Orientation
  var tail: Tail
}

구조체 는 swift 의 first type 이기 때문에, dictionary 의 키 타입으로 사용할 수 있다.

image

  • 키 타입을 String 에서 구조체 인 Attributes 로 변경했다.
  • String 을 키 타입으로 사용해 메소드가 호출될 때마다 heap allocation 이 발생하던 이전과 달리, 값 타입인 Attributes 구조체 인스턴스를 생성함으로써 이제 heap allocation 은 더 이상 일어나지 않는다.
  • 또한 올바르지 않은 키 값 (예를 들면 강아지 이름이나 사람 이름같은 문자열)이 들어갈 가능성이 없다. 바꿔 말하면 이제 type safe 하다.

Reference counting

이제 Reference couting 에 대해서 알아보도록 하자. static 한 life time을 가지는 값 타입과 달리, 클래스 는 dynamic 한 life time 을 가지기 때문에 Heap 에 할당되는 인스턴스들을 언제 메모리에서 해제할 지 결정하는 방법이 필요하다.

Swift 에선 heap 메모리에 할당된 인스턴스들에 대한 reference counting 을 통해 인스턴스의 life time 을 동적으로 관리한다.
인스턴스의 heap 메모리를 참조하는 값이 0이 되면, 더 이상 아무것도 해당 인스턴스를 참조하지 않는다는 뜻이므로, heap 메모리에서 해제된다.

reference count 를 증가시키거나 감소시키는 건 매우 자주 일어나는 연산이고, 단순히 integer 값을 증가하거나 감소시키는 것보다 더 많은 작업을 요구한다.

  • 참조 타입인 만큼 여러 쓰레드에 의해 접근될 가능성이 존재한다. 따라서 thread safety 해야하고, 항상 atomic 하게 참조 카운트를 증감시켜야 한다.

구조체 with reference properties

그렇다면 구조체 의 경우는 어떨까? 구조체 는 값 타입이고 Stack 영역에 할당되기 때문에, Reference Couting 을 하지 않는다. 왜냐하면 구조체 가 사용되는 scope 가 끝날 경우 메모리에서 해제되기 때문이다. 그러나 다음과 같이 내부에 reference type 들을 가지는 구조체 의 경우는 어떠할까?

image

  • String 은 character 들을 heap 에 저장함
  • UIFont 는 클래스 타입으로 heap 에 할당됨

Label 구조체 자체는 값 타입이라 reference counting 이 필요없다. 그러나 프로퍼티인 text 와 font 의 경우 reference counting 이 필요하다. 따라서 위 구조체 를 메모리 구조상으로 표현한다면 다음과 같다.

image

  • Label 은 구조체 라 Stack 에 할당되지만, Reference type 인 프로퍼티들은 reference Counting 을 통해 관리된다.
  • 두 인스턴스의 프로퍼티인 text 와 font 모두 참조 카운트가 2가 되고, 구조체 가 stack 에서 해제될 때 참조 카운트가 1씩 줄어들어 결국 0이 되어 heap 에서 해제된다.

만약 Reference type 을 프로퍼티로 가지는 구조체 인 경우, 프로퍼티들은 Reference Counting 을 통해 관리된다. 사실 구조체 안에 reference type 이 많으면 많을 수록, 성능이 기하급수적으로 하락한다. 10 개의 참조 타입 프로퍼티를 가지는 구조체 가 있다고 생각해보자.

구조체 Some구조체 {
  var referenceType: Some클래스 = Some클래스()
  var referenceType: Some클래스 = Some클래스()
  var referenceType: Some클래스 = Some클래스()
  var referenceType: Some클래스 = Some클래스()
  var referenceType: Some클래스 = Some클래스()
  var referenceType: Some클래스 = Some클래스()
  var referenceType: Some클래스 = Some클래스()
...
}

이 구조체 를 복사할 때마다, 내부에 존재하는 모든 reference 타입의 참조 카운팅을 1씩 증가시켜야 한다. 즉 한 번의 복사가 7개의 reference count 변경을 요구하는 것이다. 이것은 엄청난 성능 저하로 이어질 수 있다.

따라서 reference type 이 많은 구조체 의 경우, 오히려 구조체 타입 대신 클래스 타입을 사용하는게 더 나을 수도 있다. 왜냐하면 클래스 타입은 내부 프로퍼티의 참조 카운트를 증감할 필요가 없이, 오직 클래스 타입 자체의 참조 카운트만 증감시키면 되기 때문이다.

또 다른 예제를 통해 알아보자.

image

사용자는 채팅 메시지에 단순 String 뿐만 아니라, 이미지, gif 와 같은 첨부 파일도 넣을 수 있다. 이런 첨부 파일을 나타내는 Attachment 구조체 를 구현했다.

  • 프로퍼티인 fileURL, uuid, mimeType 모두 참조 타입이다.
  • 3개의 프로퍼티가 모두 참조 타입이므로, heap allocation 이 발생한다.
  1. String 타입인 uuid 프로퍼티를 UUID 타입으로 변경하자. UUID 는 값 타입으로, 이제 더 이상 uuid 프로퍼티에 대한 reference counting 이 일어나지 않는다.
  2. String 타입인 mimeType 을 enumeration 으로 변경해보자.

image

enumeration 덕분에, String 에 비해 mimeType 이 훨씬 type safe 해졌고, 또한 enum 역시 값 타입이기 때문에 불필요한 참조 카운팅 오버헤드, heap allocation 또한 줄어들게 되었다.

Method dispatch

swift 및 기타 객체지향 언어는, 다형성이라는 개념 때문에 컴파일 타임 의존성과 런타임 의존성이 다를 수 있다. 즉 런타임 의존성과 컴파일 타임 의존성이 다르면 = (컴파일 타임엔 추상화에 의존하면), 컴파일 타임에 정확히 어떤 구현 메소드를 호출해야 할 지 알 수 없다. 이런 경우 runtime 에 어떤 구현 타입의 메소드를 호출할 지 결정해야 하는데, 이를 dynamic dispatch 라고 한다.

그러나 컴파일 타임에 어떤 구현 메소드를 호출해야 하는 지 아는 경우, 즉 런타임 의존성과 컴파일 타임 의존성이 같은 경우에, runtime 에 direct 하게 해당 구현 메소드를 호출할 수 있는데, 이것을 static dispatch 라 한다.

Static dispatch 의 장점

  • Runtime 에 구현 메소드를 direct 하게 호출할 수 있다.
  • 컴파일 타임과 런타임 의존성이 같기 때문에, 컴파일러는 Inlining 과 같이 여러 가지 최적화 작업을 수행할 수 있다.
  • static dispatch 는 컴파일러에 의한 최적화 작업이 가능하기 때문에 dynamic dispatch 에 비해 성능상 이점이 존재한다.

Inlining 이 뭘까?

image

Inlining 이 무엇인지 알기 위해, 위 코드를 보자.

  • Point 구조체 는 값 타입으로, 가지고 있는 프로퍼티들 또한 값 타입이다.
  • draw 메소드가 존재한다.
  • drawAPoint 라는, Point 구조체 를 받아서 draw 메소드를 호출하는 단순한 메소드가 존재한다.
  • Point 의 인스턴스를 생성해, drawAPoint 메소드를 호출한다.

아주 간단한 예제로, 다음과 같은 순서로 메소드가 호출된다.

  1. Point 인스턴스를 생성해 point 상수에 할당한다.
  2. drawAPoint 메소드를 호출해 point 를 인자로 넘긴다.
  3. drawAPoint 메소드 내부에서 point 의 draw 메소드를 호출한다
  4. draw 메소드가 실행된다.

여기서 중요한 점은, 컴파일 타임에 drawAPoint 호출에 의해 정확히 어떤 메소드들이 호출되야 하는지 컴파일러가 알 수 있다는 점이다.

즉 어차피 마지막의 drawAPoint 를 호출하는 것은, point.draw() 메소드를 호출하는 것과 동일하다. 따라서 메소드 호출이 다음과 같이 구현으로 대체된다.

image->image

또한 point 의 draw 메소드도 static 하게 호출되기 때문에, 다음과 같이 draw 메소드의 실제 구현으로 대체할 수 있다.

image

메소드를 호출할 때 해당 메소드가 사용할 메모리 영역이 stack 에 추가된다는 사실을 기억하라, 그리고 메소드가 끝나면, 해당 메소드가 사용 중이던 메모리 또한 해제된다. 하지만 위와 같이 static dispatch 를 실제 구현으로 대체함으로써, 메소드 호출에 따른 메모리 할당 등과 같은 작업이 없어졌다는 점이 중요하다.

즉 컴파일러에 의한 최적화가 일어난 것이다. 이것이 static dispatch 가 dynamic dispatch 보다 빠른 이유다.

위와 같이 static dispatch 가 연속적인 체인을 구성할 때, 더더욱 차이가 두드러진다. 10번의 연속적인 메소드 호출이 일어난다고 생각해보자. 만약 모든 호출이 static 하다면, 10번의 call stack 은 하나의 구현 코드로 대체된다.

그러나 dynamic dispatch 의 경우 컴파일 타임에 정확한 구현 메소드를 추론하는게 불가능하므로, 위와 같은 장점들을 잃게 된다.

Dynamic dispatch

위에서 다형성이라는 개념 때문에 dynamic dispatch 라는 개념 또한 존재한다는 것을 설명했다. 다형성이란 컴파일 타임 의존성과 런타임 의존성이 다르다는 것, 즉 하나의 역할을 여러 구현 클래스들이 대체할 수 있다는 점을 강조한다. 이것은 정말 매우매우 중요한 개념 중에 하나다.

예제를 통해 알아보자.

image

  • Drawable 이란 추상 클래스가 존재한다.
  • Point 와 Line 모두 Drawable 이란 추상 클래스를 상속해 draw 라는 메소드를 재정의 하였다.
  • Drawable 이란 타입을 저장하는 배열을 만들었다.

여기서 Drawable 은 참조 타입이기 때문에, 배열의 각 인덱스는 실제 인스턴스에 대한 주소값을 참조하고 있을 것이다. 즉 모든 원소의 크기가 동일하다.

image

이제 for 문을 돌면서 각 Drawable 타입 인스턴스의 draw 메소드를 호출한다.

image

여기서 drawables 라는 배열은 Drawable 이란 타입을 저장하는 것을 주목하라. 컴파일러는 각 배열을 돌면서 이 Drawable 이라는 타입이 실제로 Point 타입일지, 아니면 Line 타입일 지 결정할 수 없다. 즉 런타임이 되서야 정확히 어떤 구현 메소드를 호출할 지 결정할 수 있는 것이다.

그렇다면 컴파일러는 어떻게 런타임에 구현 타입의 draw 메소드를 호출할까?

image-20220813105449342

컴파일러는 우선 클래스 타입 정보에 대한 것을 정적 메모리에 저장한다. 그리고 클래스 타입 정보(Line.Type과 같은) 에 대한 포인터를 클래스에 추가한다.

Line.Type 과 같은 클래스 타입 정보는 실제로 호출할 구현메소드를 알고 있으며, 실제 구현 메소드에 암시적으로 Line 인스턴스를 전달한다.

Method dispatch 정리

기본적으로 클래스 타입은 메소드를 dynamic dispatch 한다. dynamic dispatch 만으로는 큰 차이를 나타내지 않지만, 컴파일러의 가시성 (실제 구현에 대한) 을 차단하기 때문에 inlining 과 같은 최적화를 수행할 수 없다.

그러나 모든 클래스가 dynamic dispatch 할 필요가 없는데, 만약 클래스 가 더 이상 상속될 필요가 없다면, final 키워드를 통해 이 클래스는 더 이상 상속될 필요가 없다는 의도를 팀원들, 혹은 미래의 나 자신에게 전달할 수 있다.

또한 더 이상 클래스가 다형성을 지원하지 않기 때문에, 메소드를 dynamic dispatch 할 필요가 없다.

@kimscastle kimscastle self-assigned this May 4, 2023
@kimscastle kimscastle changed the title Understanding Swift Performance Understanding Swift Performance(1/2) May 12, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant