[TIL]06.16

rbw·2022년 6월 17일
0

TIL

목록 보기
26/97
post-thumbnail

Swift 성능 이해하기

2017.12.21 레츠 스위프트의 유튜브 영상을 참고 하였습니다.

Value Type 특징

값 타입은 참조 타입과는 다르게 값을 할당할 때 스택에 값 전체가 저장이 된다. Heap을 쓰지 않으며, Reference Counting 필요가 x

성능에 영향을 미치는 3가지

  • Memory Allocation이 Stack 인지 Heap 인지
  • Reference Counting 적은지 많은지
  • Metohd Dispatch 가 Static인지 Dynamic인지

(먼저 말하자면, 왼쪽에 있는 요소들이 성능에 좋은 영향을 끼침)

Heap 할당의 문제

  • 할당시에 빈 곳을 찾고 관리하는 것은 매우 복잡한 과정이다.
  • 무엇보다 그 과정이 thread safe 해야 한다는 점이 가장 큰 문제 -> lock 등의 synchronization동작은 성능에 큰 저하요소이다.

Reference Counting 문제

이는 정말 자주 실행이 된다, 변수를 copy 할 때도 일어남. 그러나 이것의 가장 큰 문제 또한 thread safety 때문이다. -> count를 Atomic하게 늘리고 줄여야 할 필요가 있음

class MyClass {}

func foo(c: MyClass) {} 

do {
    let c0: MyClass = MyClass() // retain(c0) -> Ref + 1
    var c1: MyClass = c0 // retain(c1) -> Ref + 1
    foo(c0) // retain(c) -> Ref + 1 ... release(c) -> Ref - 1

    c1 = nil 
    // release(c1) -> Ref - 1 -> release(c0) -> Ref - 1
    // Ref = 0 이 된다.
}

위 코드에서 참조 카운팅은 3번이 일어나고, 수행이 끝나거나, 할당이 nil 이 되는 경우 카운팅은 감소해서 0으로 줄여진다. retain, release 메소드가 자동으로 수행이 되는데 이를 ARC(Automatic Reference Counting)이라고 부른다. (이를, 옛날에는 직접 쳤다는,,? ㄷ ㄷ)

Method Dispatch (Static)

컴파일 시점에 메소드의 실제 코드 위치를 안다면, 실행중 찾는 과정 없이 바로 해당 코드주소로 점프가 가능하다. -> 이는 컴파일러의 최적화, 메소드 인라이닝이 가능하다.

Method Inlining

컴파일 시점에서 메소드 호출 부분에 메소드 내용을 붙여 넣음(컴파일러가 효과가 있다고 판단하는 경우만) 이를 수행하면 Call Stack 오버헤드가 감소하고 이는 CPU iCache나 레지스터를 효율적으로 쓸 가능성이 높아진다.

Method Dispatch (Dynamic)

컴파일 시점에서 확인 할 수 없는 경우이다. Reference 시맨틱스에서의 다형성 -> Compiler가 판단하기 힘든 경우 Dynamic으로 일어난다 수행과정은 다음과 같다.

  1. class의 실제 type을 얻고,(이는 런타임에만 알 수 있다)
  2. 그 classtype에 속한 V-Table을 찾아서,
  3. 실제 함수의 코드 주소를 알아내어 호출한다.

vtable (Virtual Dispatch Table) : Swift에서 클래스마다 유지하는 것, 이는 함수 포인터들의 배열로 표현되며, 하위 클래스가 메소드를 호출할 때 이 배열을 참조하여 실제 호출할 함수를 결정한다.

요점은 실제 Type을 컴파일 시점에 알수가 없다는 점이다. 때문에 컴파일러의 최적화를 못하는것이 문제이다. -> 이를 final, private 등을 사용하여, 해당 메소드 프로퍼티 등은 상속되지 않음을 나타내어 Static하게 처리를 하게끔 버릇을 들여야한다.

스위프트의 추상화 기법들의 성능

먼저 클래스를 살펴보면

  • Memory Allocation : Heap
  • Reference Counting : Yes
  • Method Dispatch : Dynamic (but final class -> Static)

그 다음 구조체는

  • Memory Allocation : Stack
  • Reference Counting : No (참조 타입이 내부에 있다면 Yes)
  • Method Dispatch : Static

String은 Value Semantics 이지만, 내부 storage로 class type을 가지고 있다. copy 시 해당 프로퍼티에 Reference Counting을 수행함. Array, Dictionary도 마찬가지

값의 제한이 가능하다면 enum 등의 값 타입으로 변경하고, 다수의 클래스를 하나의 클래스로 몰아주면 좋다.

프로토콜 타입에서 성능을 살펴보면

프로토콜 내부에서 함수를 찾고, 워드 단위로 데이터를 받고, VWT, PWT는 저번에 작성한 내용이 있으므로 생략 하겠습니다.

작은 사이즈의 프로토콜 타입은

  • Memory Allocaiton : Stack
  • Reference Counting : No
  • Method Dispatch : Dynamic (PWT)

큰 사이즈의 프로토콜 타입은

  • Memory Allocaiton : Heap Many ! (힙 할당의 문제)
  • Reference Counting : No (class property가 있을 때만 yes)
  • Method Dispatch : Dynamic (PWT)

3워드가 넘어가는 프로토콜에서는 힙 할당이 자주 이루어 지는데 이는 성능에 큰 악영향을 끼친다. 이를 개선하기 위해서는 Indirect Storage 를 사용하거나, class 타입의 간접 저장소로 이동하는 방법이 있다.

큰 사이즈의 프로토콜 카피의 힙 할당의 개선책

힙 할당을 개선하기 위해 나온 방법 중 하나로, Indirect Storage with Copy-on-Write 가 존재한다. 이는 sharing을 하다가, write가 들어오면 copy를 하는 메커니즘이다.

class LineStorage { var x1, y1, x2, y2: Double}
struct Line: Drawble {
    var storage: LineStorage
    init() { stroage = LineStorage(Point(), Point())}
    func draw() {...}
    mutating func move() {
        if !isUniquelyReferenceNonObjc(&storage) {
            storage = LineStorage(storage)
        }
        storage.start = ...
    }
}

위 코드는 reference countunique 하지 않다면 Sharing을 막기 위해 새로운 LineStorage 인스턴스를 생성(copy) 하도록 하는 예제이다.

Indirect Storage를 사용한 큰 사이즈 프로토콜 타입에서는

  • Memory Allocaiton : Heap
  • Reference Counting : Yes
  • Method Dispatch : Dynamic (PWT)

Protocol Type 에서의 메소드 구현

프로토콜 타입에서 메소드 구현 방식에 따른 차이에 대해서 알아보겠습니다.

본체에 있는 기능을 extension 으로 추가한 경우

하위 클래스들이 메소드들을 구현하고 있음이 반드시 보장이 됩니다. 구현을 하지 않았더라도 디폴트 메소드를 사용하면 됩니다. 따라서 Witness Table을 이용한 Dynamic Dispatch 가 이루어 집니다.

 protocol SomeProtocol {
       func action() -> Int
   }

   extension SomeProtocol {
       func action() -> Int { 4 }
   }

   class SomeClass: SomeProtocol {
       func action() -> Int { 2 }
   }

   class DerivedClass: SomeClass {
       override func action() -> Int { 3 }
   }  

   var c: SomeProtocol = SomeClass() 
   print(c.action()) // 2

   c = DerivedClass()
   print(c.action()) // 3

본체에 없는 기능을 extension 으로 추가한 경우

본체에 선언하지 않고 extension 으로 추가한 메소드들은 Witness Table을 이용할 수 없습니다. 따라서 Static Dispatch 가 적용 됩니다.

 protocol SomeProtocol {}

   extension SomeProtocol {
       func action() -> Int { 4 }
   }

   class SomeClass: SomeProtocol {
       func action() -> Int { 2 }
   }

   class DerivedClass: SomeClass {
       override func action() -> Int { 3 }
   }  

   var c: SomeProtocol = SomeClass() 
   print(c.action()) // 4

   c = DerivedClass()
   print(c.action()) // 4

단위 테스트에 관하여

각 테스트의 ‘단위’를 얼만큼으로 잡을 것인가는 테스트의 속도 vs 효과성 트레이드오프를 파악하여 정해야 한다.

단위를 매우 작게 잡으면 속도는 빠르지만 실세계와 테스트 환경의 괴리가 크므로 효과성이 낮다. 단위를 크게 잡을수록 속도는 느리지만 효과성이 높다.

테스트 대상을 최대로 크게 잡은게 UI Test고, 가장 작게 잡은건 sut가 객체 하나짜리인 단위 테스트라고 볼 수 있다.

모바일 앱에서 테스트가 필요한 여러 기능 중 중요도가 높은건 사용자의 인터랙션과 복잡하게 얽힌 플로우다

Hammer 라이브러리

이는 RIBs로 치면 뷰, 인터랙터, 라우터를 전부 실 객체로 테스트할 수 있는 것. 더 나아가 인터랙터가 호출하는 서비스 계층도 실 객체를 쓸 수도 있다.

모멘티 프로젝트는 서비스 계층이 의존하고 있는 플랫폼 계층(네트워킹, 데이터 저장, 엔진 등)이 한 단계 더 있어서 실 서비스 객체까지 테스트할 수 있다.

Hammer를 사용하면 테스트의 인풋은 실세계와 매우 유사한 유저 터치이며 모킹된 플랫폼 계층에서 최종 행위/상태 검증을 할 수 있다. 신뢰도가 높으면서 유연하고 빠른 테스트를 만들 수 있다.


참고

https://jcsoohwancho.github.io/2019-11-01-Swift%EC%9D%98-Dispatch-%EA%B7%9C%EC%B9%99/

https://velog.io/@yohanblessyou/Apple-Understanding-Swift-Performance-2.-%ED%94%84%EB%A1%9C%ED%86%A0%EC%BD%9C%EB%A1%9C-value-type-%EB%8B%A4%ED%98%95%EC%84%B1-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0

https://soojin.ro/blog/touch-simulation-unit-tests

profile
hi there 👋

0개의 댓글