1. 1. 스위프트 성능 파헤치기
    1. 1.1. Value Semantics & Reference Semantics
      1. 1.1.1. Value Semantics
      2. 1.1.2. Value Type의 특징
        1. 1.1.2.1. 실험
          1. 1.1.2.1.1. 구조체에 Value Type 프로퍼티가 존재할 때의 동작
          2. 1.1.2.1.2. 클래스에 Value Type 프로퍼티가 존재할 때의 동작
      3. 1.1.3. Value Semantics : ‘값’에 의해 구분된다
      4. 1.1.4. Value Type과 Thread
      5. 1.1.5. 복사 시 성능 문제?
        1. 1.1.5.1. 실험
          1. 1.1.5.1.1. 정해진 시간은 무엇을 의미하는 것일까?
          2. 1.1.5.1.2. Array의 요소가 힙에 저장된다고?
      6. 1.1.6. Reference Type을 Immutable하게 만들면 되지 않는가?
        1. 1.1.6.1. Immutable한 설계가 어울리지 않는 경우
          1. 1.1.6.1.1. Immutable 객체를 갱신하기 위해 매번 새로운 객체를 만들고 할당하는 경우
          2. 1.1.6.1.2. API가 이상해지는 경우
      7. 1.1.7. 그래도 클래스는 중요하다.
    2. 1.2. 성능을 위해 고려할 것들
      1. 1.2.1. 메모리 할당
        1. 1.2.1.1. 힙 할당 문제
        2. 1.2.1.2. 힙 할당 줄이기
      2. 1.2.2. 참조 카운팅 발생 여부
        1. 1.2.2.1. 참조 카운팅의 문제
        2. 1.2.2.2. 실험
          1. 1.2.2.2.1. 참조 카운팅 직접 확인
      3. 1.2.3. 메소드 디스패치
        1. 1.2.3.1. 정적 메소드 디스패치
          1. 1.2.3.1.1. 메소드 인라이닝
        2. 1.2.3.2. 동적 메소드 디스패치
        3. 1.2.3.3. 동적 메소드 디스패치의 문제
        4. 1.2.3.4. Objective-C의 메소드 디스패치
        5. 1.2.3.5. 정적 디스패치로 강제하기
        6. 1.2.3.6. 실험
          1. 1.2.3.6.1. final class vs. class
            1. 1.2.3.6.1.1. final class
            2. 1.2.3.6.1.2. class
          2. 1.2.3.6.2. final method vs. normal method
            1. 1.2.3.6.2.1. final method
            2. 1.2.3.6.2.2. normal method
      4. 1.2.4. 정리
    3. 1.3. 스위프트의 추상화 기법들의 성능
      1. 1.3.1. Class
        1. 1.3.1.1. Class
        2. 1.3.1.2. Final Class
      2. 1.3.2. Struct
        1. 1.3.2.1. 참조 타입을 갖지 않는 Struct
        2. 1.3.2.2. 참조 타입을 갖는 Struct
        3. 1.3.2.3. 참조 타입을 갖는 Struct가 참조 타입을 적게 갖도록 리팩토링하기
      3. 1.3.3. Protocol Type
        1. 1.3.3.1. Protocol
        2. 1.3.3.2. Protocol을 이용한 Value Type 다형성
        3. 1.3.3.3. 프로토콜 타입을 사용할 때의 의문
        4. 1.3.3.4. Existential Container 정리
        5. 1.3.3.5. 작은 사이즈(3워드 이하)의 Protocol Type
        6. 1.3.3.6. 큰 사이즈(3워드 초과)의 Protocol Type
        7. 1.3.3.7. Indirect Storage 사용하여 개선한 큰 사이즈 Protocol Type
        8. 1.3.3.8. 실험
          1. 1.3.3.8.1. 프로토콜 타입일 때와 해당 타입일 경우의 차이는?
          2. 1.3.3.8.2. 3워드 초과 / 이하 차이로 인한 값 저장 동작 변화
          3. 1.3.3.8.3. 3워드 초과 / 이하 차이로 인한 Copy 동작 변화
            1. 1.3.3.8.3.1. 3워드 이하
            2. 1.3.3.8.3.2. 3워드 초과
          4. 1.3.3.8.4. VWT와 PWT는 힙에 위치하는가?
          5. 1.3.3.8.5. Copy-on-Write가 발생하는가?
          6. 1.3.3.8.6. 큰 크기의 Protocol Type vs. Indirect Storage
      4. 1.3.4. Generic Type
        1. 1.3.4.1. 실험
          1. 1.3.4.1.1. 정적 다형성이 구현된 것을 확인해볼 수 있을까?
        2. 1.3.4.2. 특수화되지 않은 Generics (작은 크기의 Protocol Type)
        3. 1.3.4.3. 특수화되지 않은 Generics (큰 크기의 Protocol Type)
        4. 1.3.4.4. 특수화된 Generic Type (Struct)
        5. 1.3.4.5. 특수화된 Generic Type (Class)
        6. 1.3.4.6. 정리
    4. 1.4. 마무리
      1. 1.4.1. 추상화 기법의 선택
      2. 1.4.2. 고려할 수 있는 성능 최적화 기법들
      3. 1.4.3. 마무리
    5. 1.5. 더 알아볼 것들
      1. 1.5.1. Swift의 String

Swift 성능 이해하기

스위프트 성능 파헤치기

2016년 Let’Swift의 Swift 성능 이해하기: Value 타입, Protocol과 스위프트의 성능 최적화 세션 내용을 공유한다.

메모리나 저수준 코드의 형태로 보여줄 수 있다면 보여주어 이해를 최대한 돕는다.

Value Semantics & Reference Semantics

Value Semantics

Copy-by-Value. 인스턴스가 할당된 메모리의 주소와 관련 있는 Identity가 아닌, 값 자체에만 의미를 둔다.

값이 넘어갈 때 값을 복사하여 넘긴다.

Swift는 Objective-C에는 없는 Struct, Enum, Tuple 등의 Value Type이 있다.

Value Type의 특징

  • 변수 할당시 스택 영역에 값 전체가 저장된다.
  • 다른 변수에 할당될 때 값 전체가 복사된다.
    • 변수들이 서로 분리되므로 한 변수에 대한 변경이 다른 것에 영향을 미치지 않는다.
  • 힙 영역을 사용하지 않는다. 그러므로 참조 카운팅도 필요하지 않다.

실험

구조체에 Value Type 프로퍼티가 존재할 때의 동작
struct ValueValue {
var value = 10
}

func foo() {
var bar1 = ValueValue()
var bar2 = bar1
bar2.value = 11
}

foo()
bar2 : (7FFEEFBFF4B0) : 0B 00 00 00 00 00 00 00
bar1 : (7FFEEFBFF4B8) : 0A 00 00 00 00 00 00 00

지역 변수가 저장된 주소는 스택 영역이다.

구조체가 Int 타입 프로퍼티를 하나 가지므로 8바이트가 할당된다.

값 전체를 복사하여 변수들이 서로 분리되는 모습을 확인할 수 있다.

클래스에 Value Type 프로퍼티가 존재할 때의 동작
class ReferenceValue {
var value = 10
}

func foo() {
var bar1 = ReferenceValue()
var bar2 = bar1
bar2.value = 11
}

foo()
bar2 : (7FFEEFBFF4A0) : 20 FF 0A 03 01 00 00 00
bar1 : (7FFEEFBFF4A8) : 20 FF 0A 03 01 00 00 00

(1030AFF20) : 80 8F 00 00 01 00 00 00
02 00 00 00 02 00 00 00
0B 00 00 00 00 00 00 00

지역 변수가 저장된 주소는 스택 영역이다. 클래스의 인스턴스는 힙에 할당되고, 그 주소가 지역 변수에 저장된다. 해당 인스턴스에 대한 참조 카운트가 1로 설정된다.

64비트 시스템에서 주소는 8바이트이므로 지역 변수를 위해 8바이트가 할당된다.

var bar2 = bar1에 의해 클래스의 인스턴스의 참조 카운트가 증가하여 2가 된다.

1030AFF20 주소로 가보면 위와 같은 결과를 확인할 수 있다.

  • 첫 8바이트의 100008F80 주소는 데이터 영역이다. (TODO: 무엇이 들어있을까?)

  • 다음 8바이트 (TODO: 무엇이 들어있을까?)

  • 마지막 8바이트에 11이 저장되어 있다.

참조가 복사되었으므로 변수들이 분리되지 않은 모습을 확인할 수 있다.

Value Semantics : ‘값’에 의해 구분된다

  • Identity가 아닌 Equality가 중요하다. 각 변수는 값에 의해 구분되어야 한다.
  • Swift는 Equatable 프로토콜을 구현하여 Equality를 제공할 수 있다. (동등 연산자 구현)

Value Type과 Thread

  • Value Type은 쓰레드 안전하다. 값을 공유하는 것이 아닌, 값을 복사하여 넘기기 때문이다.
  • 쓰레드 간 의도하지 않은 공유로부터 안전한다.

복사 시 성능 문제?

  • 복사는 빠르다.
    • Struct, Enum, Tuple의 복사
      • 정해진 시간(constant time) 안에 작업이 완료된다.
    • Struct의 내부 데이터가 힙에 존재하는 경우 (String, Array, Set, Dictionary 등)
      • 정해진 시간 + 참조 복사 시간
      • Copy-on-Write를 통해 복사로 인한 속도 저하를 보완한다. 이를 통해 Value Semantics를 구현한다.

실험

정해진 시간은 무엇을 의미하는 것일까?

Struct, Enum, Tuple의 복사에 일어나는 정해진 시간은 현실적으로 실험하기 어렵다.

Array 복사를 통해 정해진 시간을 알아보자.

func foo() {
let iterationCounts = [10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000]
for iterationCount in iterationCounts {
print("Iteration Count : \(iterationCount)")
var array = [Int]()
for number in 0 ..< iterationCount {
array.append(number)
}
var startTime = CFAbsoluteTimeGetCurrent()
let array2 = array
print("배열 복사 : \(CFAbsoluteTimeGetCurrent() - startTime)")
startTime = CFAbsoluteTimeGetCurrent()
var array3 = array
array[0] = 1
print("배열 복사 후 쓰기 : \(CFAbsoluteTimeGetCurrent() - startTime)")
print("------------------------")
}
}

foo()
Iteration Count : 10
배열 복사 : 1.0728836059570312e-06 (약 0.000001초 (1마이크로초))
배열 복사 후 쓰기 : 2.205371856689453e-05 (약 0.00002초 (20마이크로초))
------------------------
Iteration Count : 100
배열 복사 : 0.0
배열 복사 후 쓰기 : 9.5367431640625e-07 (약 0.0000009초 (0.9마이크로초))
------------------------
Iteration Count : 1000
배열 복사 : 9.5367431640625e-07 (약 0.0000009초 (0.9마이크로초))
배열 복사 후 쓰기 : 2.0265579223632812e-06 (약 0.000002초 (2마이크로초))
------------------------
Iteration Count : 10000
배열 복사 : 2.0265579223632812e-06 (약 0.000002초 (2마이크로초))
배열 복사 후 쓰기 : 3.898143768310547e-05 (약 0.00003초 (30마이크로초))
------------------------
Iteration Count : 100000
배열 복사 : 3.0994415283203125e-06 (약 0.000003초 (3마이크로초))
배열 복사 후 쓰기 : 0.0003249645233154297 (약 0.0003초 (0.3밀리초))
------------------------
Iteration Count : 1000000
배열 복사 : 4.0531158447265625e-06 (약 0.000004초 (4마이크로초))
배열 복사 후 쓰기 : 0.0033320188522338867 (약 0.003초 (3밀리초))
------------------------
Iteration Count : 10000000
배열 복사 : 2.9802322387695312e-06 (약 0.000002초 (2마이크로초))
배열 복사 후 쓰기 : 0.03259694576263428 (약 0.03초 (30밀리초))
------------------------
Iteration Count : 100000000
배열 복사 : 2.0265579223632812e-06 (약 0.000002초 (2마이크로초))
배열 복사 후 쓰기 : 0.34505295753479004 (약 0.3초 (300밀리초))
------------------------

실험 결과 배열의 복사는 마이크로초 단위의 정해진 시간 안에 수행되는 것을 확인할 수 있다.

Copy-on-Write에 의해 복사 후 쓰기가 일어날 때 복사되며, 배열 복사 후 쓰기 시간이 갈수록 증가한다는 것을 확인할 수 있다.

이처럼 복사가 성능에 영향을 미칠 것으로 생각되지만 복사는 정해진 시간 안에 일어나 매우 빠르며, 내부 데이터를 힙에 할당하는 Value Type에 대해서도 Copy-on-Write를 통해 쓰기 시에 실제 복사가 일어나게 하여 성능 저하 요소를 해결한다.

Array의 요소가 힙에 저장된다고?
func foo() {
var array = [1, 2, 3, 4, 5]
withUnsafeBytes(of: &array) { print($0) }
}

foo()
UnsafeRawBufferPointer(start: 0x00007ffeefbff4a8, count: 8)
array : (7FFEEFBFF4A8) : 00 25 53 00 01 00 00 00
(100532500) : A0 19 B3 94 FF 7F 00 00
02 00 00 00 00 00 00 00
05 00 00 00 00 00 00 00
0A 00 00 00 00 00 00 00
01 00 00 00 00 00 00 00
02 00 00 00 00 00 00 00
03 00 00 00 00 00 00 00
04 00 00 00 00 00 00 00
05 00 00 00 00 00 00 00

Array는 Struct로 구현되었지만 실제로는 주소를 저장하기 위한 8바이트를 할당받고 힙에 배열의 요소를 저장하고 있음을 확인할 수 있다.

Reference Type을 Immutable하게 만들면 되지 않는가?

  • Reference Type이라도 Immutable하면 쓰레드 간 공유에 문제가 생길 일이 없다.
    • 불변 == 인스턴스가 만들어지고 나서 수정할 수 없기 때문
  • Foundation 프레임워크에도 NSArray, NSAttributedString과 같은 불변 객체가 정의되어 있다.
  • 하지만 언제나 Immutable한 것이 좋은 것은 아니며, Mutable한 것이 효율적인 경우가 있다.

Immutable한 설계가 어울리지 않는 경우

Immutable 객체를 갱신하기 위해 매번 새로운 객체를 만들고 할당하는 경우
var array = NSArray()
for number in [1, 2, 3] {
array = array.adding(number) as NSArray
}

이러한 경우 아래와 같이 Immutable한 NSArray 대신 NSMutableArray를 사용하여 Mutable 객체에 새로운 객체를 추가하는 방식을 취하는 것이 효율적이다.

var array = NSMutableArray()
for number in [1, 2, 3] {
array.add(number)
}
API가 이상해지는 경우
// Mutable API
car.dashboard.speed = 100
// Immutable API
car.dashboard = Car.Dashboard(speed: 100)

Car.Dashboard가 Immutable한 경우 speed 프로퍼티를 바꿔야 할 때마다 새로운 객체를 만들어 할당해 주어야 한다.

사람이 볼 때도 어울리지 않고, 객체를 새로 할당하므로 컴파일러가 최적화하기도 어렵다.

그래도 클래스는 중요하다.

  • Identity가 Equality보다 중요한 경우
  • 객체 지향 프로그래밍
    • 상속은 여전히 훌륭한 도구다.
  • Objective-C 연동
    • Cocoa / Cocoa Touch 프레임워크는 클래스로 구성되었다.
  • Indirect Storage
    • 특수한 경우 Struct 내부의 간접 저장소 역할을 한다.

성능을 위해 고려할 것들

성능에 영향을 미치는 것들

  1. 메모리 할당 : 스택 or 힙
  2. 참조 카운팅 발생 여부 : true or false
  3. 메소드 디스패치 : 정적 or 동적 (호출할 메소드를 결정하는 것이 컴파일 타임에 일어나는가, 런타임에 일어나는가)

메모리 할당

힙 할당 문제

힙에 할당할 때 비어 있는 곳을 찾고 관리하기 위한 오버헤드가 존재한다. 단편화 현상을 피할 수 없다.

위의 과정이 쓰레드 안전해야 하기 때문에 lock 등의 동기화 기법을 사용하며, 이것이 큰 성능 저하 요소가 된다.

반면 스택 할당은 단순히 스택 포인터 값만 바꿔주면 된다.

  • 스택 포인터 : 레지스터에 기록되어 있으며, 스택에 데이터가 채워진 위치를 가리킨다.

힙 할당 줄이기

예를 들어 String을 키 값으로 사용하는 캐시 객체가 있을 때, 키 값을 코드에서 만들 때마다 해당 값이 힙에 할당되게 된다.

이러한 행위가 빈번하게 일어난다면 성능에 영향을 미칠 수 있을 것이다.

이 경우 키를 Value Type으로 바꾸어 힙 할당을 줄여볼 수 있다.

// 이전
let key = "\(color)\(theme)\(selected)"
// 이후
enum Color { }
enum Theme { }
struct Attribute: Hashable {
var color: Color
var theme: Theme
var selected: Bool
}
let key = Attribute(color: color, theme: theme, selected: selected)

키 값은 완전하게 Value Type으로 대체되어 힙에 할당될 일이 없게 된다.

참조 카운팅 발생 여부

참조 카운팅의 문제

참조 카운팅은 알게 모르게 정말 많이 발생한다. 참조 타입의 변수를 복사할 때마다 발생하기 때문이다.

또한 참조 카운팅도 쓰레드 안전하게 발생해야 한다는 문제가 있다. 카운트를 Atomic하게 증감시켜야 한다.

class A { }
func foo(_ a: A) { }

// 1
let a0 = A()
// 2
var a1: A? = a0
// 3
foo(a0)
// 4
a1 = nil
  1. A 클래스의 인스턴스를 힙에 할당하고 그 주소를 a0 변수가 가지고 있게 한다. 해당 인스턴스의 참조 카운트는 1이 된다.
    • new -> 1
  2. c1 변수가 c0가 가리키는 인스턴스를 가리키게 한다. 해당 인스턴스의 참조 카운트는 2가 된다.
    • retain -> 2
  3. c0의 참조를 함수에 넘긴다.
    1. 함수의 매개 변수 a가 인스턴스를 참조하므로 함수가 시작될 때 해당 인스턴스의 참조 카운트는 3이 된다.
      • retain -> 3
    2. 함수를 빠져 나갈 때 해당 인스턴스의 참조 카운트는 2가 된다.
      • release -> 2
  4. c1의 참조를 nil로 바꾼다. c1에 대해 release가 발생하여 해당 인스턴스의 참조 카운트는 1이 된다.
    • release -> 1

ARC에서 retain과 release 같은 것은 컴파일러가 작성한다.

for _ in 0 ..< 1000000 {
foo(c0)
}

위의 코드처럼 참조 타입이 인자로 넘어가는 함수가 여러 번 호출된다면 retain과 release가 매우 빈번하게 발생할 수 있다. 이는 성능 저하 요소가 된다.

실험

참조 카운팅 직접 확인
class A {
deinit { print("deinit") }
}
func foo(_ a: A?) { print(CFGetRetainCount(a)) }

var a0: A? = A()
print(CFGetRetainCount(a0))
var a1: A? = a0
print(CFGetRetainCount(a0))
foo(a0)
print(CFGetRetainCount(a0))
a1 = nil
print(CFGetRetainCount(a0))
a0 = nil
2
3
4
3
2
deinit

위의 설명과 동일하다.

참조 카운트가 2부터 시작하는 것은 신경쓰지 말자…

메소드 디스패치

정적 메소드 디스패치

컴파일 시점에 메소드의 실제 위치를 알 수 있다면 런타임에서 찾는 과정 없이 바로 해당 코드가 위치한 주소로 점프할 수 있다.

이 경우 컴파일러가 코드를 최적화할 수 있는 가능성이 생기며, 메소드 인라이닝도 가능해진다.

메소드 인라이닝

컴파일 타임에 효과적이라고 판단될 때 메소드 호출 부분에 메소드 내용을 붙여 넣는다.

함수 호출 스택에 대한 오버헤드를 줄여 CPU i-cache(명령어 캐시)나 레지스터를 효율적으로 사용할 가능성이 있다.

메소드 인라이닝이 가능해지면 추가적인 최적화가 가능하다.

최근 메소드의 크기가 점점 더 작아지고 있으므로 더더욱 최적화의 기회가 많아진다.

루프 안에서 메소드가 호출되는 경우 큰 효과를 얻을 수 있다.

struct Point {
var x: CGFloat
var y: CGFloat
func draw() {
print(x, y)
}
}

func drawPoint(_ point: Point) {
point.draw()
}

let point = Point(x: 0, y: 0)
drawPoint(point)
  1. drawPoint(point) 메소드의 위치는 컴파일 타임에 결정 가능하므로 point.draw()로 대체할 수 있다.
  2. point.draw() 메소드의 위치는 컴파일 타임에 결정 가능하므로 print(point.x, point.y)로 대체할 수 있다.

결국 drawPoint(point)print(point.x, point.y)로 대체된다.

두 번의 호출이 줄었다. 코드가 붙어 추가적인 최적화의 기회가 생긴다.

동적 메소드 디스패치

다형성의 개념을 사용한 경우 정적 메소드 디스패치를 할 수 없다.

class Drawable { func draw() { } }

class Point: Drawable {
var x: CGFloat
var y: CGFloat
// init
override func draw() { ... }
}

class Line: Drawable {
var x1: CGFloat
var y1: CGFloat
var x2: CGFloat
var y2: CGFloat
// init
override func draw() { ... }
}

func draw(_ drawable: Drawable) {
drawable.draw()
}

draw() 함수는 다형성 기법을 사용하여 Drawable 타입의 객체를 인자로 요구한다.

drawable.draw()Point.draw일지, Line.draw일지, Drawable.draw일지 컴파일 타임에서 알 수 없다.

런타임에서,

  1. 클래스의 실제 타입을 찾아내고
  2. 해당 클래스 타입에 속한 V-Table을 찾고
  3. 실제 메소드(draw)의 코드 주소를 찾아내어 호출한다.

이것이 동적 메소드 디스패치다.

동적 메소드 디스패치의 문제

쓰레드 안전 문제도 없다.

하지만 실제 타입을 런타임에 찾아 메소드 코드의 주소를 찾으므로, 컴파일러가 코드를 최적화할 수 없게 된다.

Objective-C의 메소드 디스패치

Objective-C의 메소드 디스패치는 메세지 전달 방식으로 일어난다.

// 1
[object foo:parameter];
// 2
objc_msgSend(object, @selector(foo:), parameter);

Objective-C에서 1과 같이 작성된 코드는 2와 같이 Objective-C 런타임 함수를 사용하는 코드로 변환되어, 동적으로 메소드를 Lookup하여 호출한다.

이는 강력하고 유연한 특징을 가지고 있으나 메소드 인라이닝 같은 것을 할 수 없어 성능 저하 요소가 된다.

특히 루프 안에서 메소드가 빈번하게 호출되는 경우 더욱 그렇다.

정적 디스패치로 강제하기

  • final, private을 쓰는 습관을 들이기
    • 해당 메소드와 프로퍼티 등은 상속되지 않는다는 것이 보장되어 정적으로 처리되고, 컴파일 타임에 해당 주소를 알 수 있다.
    • 가능한 한 접근 수준을 최소화하기
  • dynamic 사용하지 않기
  • Objective-C 연동 최소화
    • Objective-C와 연동하는 경우 Objective-C 런타임을 통하게 되어 컴파일 타임 최적화가 힘들다.
  • WMO(whole module optimization) 활성화
    • WMO : 빌드 시에 모든 파일을 한번에 분석하여 정적 디스패치로 변환 가능한지 판단하여 최적화

실험

final class vs. class
final class
final class SomeClass {
var foo = 3
func bar() { }
}
sil_vtable SomeClass {
#SomeClass.init!allocator.1: (SomeClass.Type) -> () -> SomeClass : @$s4main9SomeClassCACycfC // SomeClass.__allocating_init()
#SomeClass.deinit!deallocator.1: @$s4main9SomeClassCfD // SomeClass.__deallocating_deinit
}
class
class SomeClass {
var foo = 3
func bar() { }
}

class DerivedSomeClass: SomeClass { }
sil_vtable SomeClass {
#SomeClass.foo!getter.1: (SomeClass) -> () -> Int : @$s4main9SomeClassC3fooSivg // SomeClass.foo.getter
#SomeClass.foo!setter.1: (SomeClass) -> (Int) -> () : @$s4main9SomeClassC3fooSivs // SomeClass.foo.setter
#SomeClass.foo!modify.1: (SomeClass) -> () -> () : @$s4main9SomeClassC3fooSivM// SomeClass.foo.modify
#SomeClass.bar!1: (SomeClass) -> () -> () : @$s4main9SomeClassC3baryyF // SomeClass.bar()
#SomeClass.init!allocator.1: (SomeClass.Type) -> () -> SomeClass : @$s4main9SomeClassCACycfC // SomeClass.__allocating_init()
#SomeClass.deinit!deallocator.1: @$s4main9SomeClassCfD // SomeClass.__deallocating_deinit
}

sil_vtable DerivedSomeClass {
#SomeClass.foo!getter.1: (SomeClass) -> () -> Int : @$s4main9SomeClassC3fooSivg [inherited] // SomeClass.foo.getter
#SomeClass.foo!setter.1: (SomeClass) -> (Int) -> () : @$s4main9SomeClassC3fooSivs [inherited] // SomeClass.foo.setter
#SomeClass.foo!modify.1: (SomeClass) -> () -> () : @$s4main9SomeClassC3fooSivM [inherited] // SomeClass.foo.modify
#SomeClass.bar!1: (SomeClass) -> () -> () : @$s4main9SomeClassC3baryyF [inherited] // SomeClass.bar()
#SomeClass.init!allocator.1: (SomeClass.Type) -> () -> SomeClass : @$s4main16DerivedSomeClassCACycfC [override] // DerivedSomeClass.__allocating_init()
#DerivedSomeClass.deinit!deallocator.1: @$s4main16DerivedSomeClassCfD // DerivedSomeClass.__deallocating_deinit
}

실제 V-Table을 살펴봤을 때, final class의 메소드는 V-Table에 위치하지 않는 반면 class의 메소드는 V-Table에 위치하는 것을 확인할 수 있다.

var로 선언된 저장 프로퍼티의 경우 프로퍼티에 대한 getter, setter, modify 메소드가 내부적으로 생성되며, 이 또한 final class의 경우 V-Table에 위치하지 않는 것을 확인할 수 있다.

final method vs. normal method
final method
class SomeClass {
final func foo() { }
}
sil_vtable SomeClass {
#SomeClass.init!allocator.1: (SomeClass.Type) -> () -> SomeClass : @$s4main9SomeClassCACycfC // SomeClass.__allocating_init()
#SomeClass.deinit!deallocator.1: @$s4main9SomeClassCfD // SomeClass.__deallocating_deinit
}
normal method
class SomeClass {
func foo() { }
}
sil_vtable SomeClass {
#SomeClass.foo!1: (SomeClass) -> () -> () : @$s4main9SomeClassC3fooyyF // SomeClass.foo()
#SomeClass.init!allocator.1: (SomeClass.Type) -> () -> SomeClass : @$s4main9SomeClassCACycfC // SomeClass.__allocating_init()
#SomeClass.deinit!deallocator.1: @$s4main9SomeClassCfD // SomeClass.__deallocating_deinit
}

final 선언이 붙은 메소드는 상속되지 않는 것을 보장하며, V-Table에 위치하지 않는 것을 확인할 수 있다.

정리

메모리 할당은 힙이 아닌 스택에 일어나게 하는 것이 좋다. 힙의 비어 있는 공간을 찾고 할당하는 것은 쓰레드 안전하게 일어나야 하며, 이 과정에서 발생하는 동기화 작업이 성능 저하를 불러 일으킨다.

참조 카운팅은 일어나지 않게 하는 것이 좋다. 참조 카운팅은 쓰레드 안전하게, Atomic하게 일어나야 하며, 이 과정에서 발생하는 동기화 작업이 성능 저하를 불러 일으킨다.

메소드 디스패치는 동적이 아닌 정적으로 일어나게 하는 것이 좋다. 정적 메소드 디스패치는 메소드 인라이닝 등 컴파일 타임에서 코드를 최적화하는 기회를 많이 주지만, 동적 메소드 디스패치는 그렇지 않다.

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

Class / Struct / Protocol Type / Generic Type

Class

Class

구분 내용
메모리 할당
참조 카운팅 발생 여부 true
메소드 디스패치 동적 (V-Table 사용)
  • 성능에 관계 없이 Reference Semantics가 필요하다면 사용해야 한다. (Identity 등)
  • 참조의 의도치 않은 공유로 인한 문제를 신경써야 한다.

Final Class

구분 내용
메모리 할당
참조 카운팅 발생 여부 true
메소드 디스패치 정적
  • final class는 상속될 수 없으므로 정적 메소드 디스패치가 일어난다.
  • 그러므로 해당 부분에서 일반 클래스보다 성능 상 이점을 취할 수 있다.

Struct

참조 타입을 갖지 않는 Struct

구분 내용
메모리 할당 스택
참조 카운팅 발생 여부 false
메소드 디스패치 정적
  • 구조체는 상속을 지원하지 않으므로 정적 메소드 디스패치가 일어난다.

참조 타입을 갖는 Struct

구분 내용
메모리 할당 스택
참조 카운팅 발생 여부 true (참조 타입 프로퍼티의 개수만큼 발생)
메소드 디스패치 정적
struct Label {
var text: String
var font: UIFont
}

// 1
let font = UIFont.systemFont(ofSize: 15)
// 2
let label = Label(text: "msg", font: font)
// 3
let label2 = label

UIFont는 클래스. String은 Struct로 구현되었으나 내부 데이터가 힙에 할당되므로 내부 데이터에 대한 참조 카운팅 발생.

  1. 1이 실행될 때 UIFont 클래스의 인스턴스가 힙에 할당되며 font 변수가 해당 인스턴스의 참조를 기억한다. 해당 인스턴스의 참조 카운트는 1이다.
  2. 2가 실행될 때 Label 구조체의 인스턴스가 스택에 할당되며 label 변수가 해당 인스턴스를 나타낸다.
    • text 프로퍼티는 String 타입이므로 내부 데이터는 힙에 인스턴스를 할당하며 1의 참조 카운트를 갖는다.
    • font 프로퍼티는 font 변수가 참조하는 인스턴스를 참조하므로 해당 인스턴스는 2의 참조 카운트를 갖는다.
  3. 3이 실행될 때 label 구조체를 복사하여 label2 변수에 할당하고 스택에 쌓인다.
    • 하지만 각 프로퍼티가 참조하는 힙에 위치한 인스턴스에 대한 참조 카운트가 증가한다.
      • text 프로퍼티의 내부 데이터가 참조하는 인스턴스는 2의 참조 카운트를 갖는다.
      • font 프로퍼티가 참조하는 인스턴스는 3의 참조 카운트를 갖는다.

이처럼 한 번의 복사가 일어날 때마다 참조 타입 프로퍼티의 개수만큼 참조 카운팅이 발생한다.

참조 타입을 갖는 Struct가 참조 타입을 적게 갖도록 리팩토링하기

struct HTTPRequest {
var `protocol`: String
var domain: String
var path: String
var filename: String
var `extension`: String
var query: [String: String]
var httpMethod: String
var httpVersion: String
var httpHost: String
}

위의 코드는 9개의 참조 타입을 갖는다. 복사할 때마다 아홉 번의 참조 카운팅이 발생한다.

enum HTTPMethod {
case get
case post
case put
case delete
}

enum HTTPVersion {
case v1
case v2
}

struct HTTPRequest {
var urlString: String
var httpMethod: HTTPMethod
var httpVersion: HTTPVersion
var httpHost: String
}

protocol, domain, path, filename, extension, query는 하나의 프로퍼티(urlString)가 모두 갖도록 바꿨다.

httpMethod / httpVersion의 값은 제한 가능하므로 String 대신 관련 열거형의 인스턴스를 갖도록 한다.

HTTPMethod 열거형을 정의하여 httpMethod 프로퍼티가 해당 타입의 값을 갖도록 한다.

HTTPVersion 열거형을 정의하여 httpVersion 프로퍼티가 해당 타입의 값을 갖도록 한다.

결과적으로 두 개의 참조 타입과 두 개의 값 타입을 갖게 된다. 복사할 때마다 두 번의 참조 카운팅이 발생한다.

값의 제한이 가능하다면 Enum과 같은 Value Type으로 변경할 수 있고, 여러 개의 클래스를 하나의 클래스로 몰아 넣을 수 있다.

Protocol Type

Protocol

  • 구현 없이 선언만 정의한다.
  • 상속 없는 다형성 구현이 가능하다.
  • Value Type인 Struct나 Enum에 적용 가능하다.
    • Value Semantics에서의 다형성 구현

Protocol을 이용한 Value Type 다형성

protocol Drawable { func draw() }
struct Point: Drawable { func draw() { ... } }
struct Line: Drawable { func draw() { ... } }

let drawables: [Drawable] = [Point(), Line()]
for drawable in drawables {
drawable.draw()
}

반복문 안에서 draw() 메소드를 호출하는 타입은 Protocol인 Drawable 타입이다.

이렇게 Value Type에서도 Protocol을 사용하여 다형성을 구현할 수 있다.

프로토콜 타입을 사용할 때의 의문

  • 변수 할당에 대한 의문

    • 클래스는 힙에 인스턴스를 생성하고 이를 참조하는 변수는 인스턴스의 주소값을 가지므로, 64비트 시스템에서 변수는 모두 8바이트의 크기를 할당받는다.

    • 구조체는 스택에 서로 다른 크기의 구조체의 인스턴스를 쌓아야 하는데, 프로토콜 타입인 경우에 어떻게 타입의 크기를 알고 값을 스택에 쌓을 수 있을까?

    • let drawables: [Drawable] = [Point(), Line()]
      
      <!--33-->
  • 이렇게 되면 각각의 Existential Container가 별도의 힙 공간을 가리키는 포인터를 갖게 하도록 할 수 있다.

    • 여전히 참조 카운팅을 하지만, 참조 카운트는 서로 1씩 갖게 된다.
    • 또한 Value Semantics를 구현하여 값을 분리할 수 있게 된다.
  • 이처럼 쓰기 전까지는 참조를 복사하고(얕은 복사), 쓰기가 발생할 때 값을 복사(깊은 복사)하여 성능 상 이점을 취한다.

  • String, Array, Dictionary 등도 이러한 개념으로 Value Semantics를 구현하였다.

Existential Container 정리

  • Protocol Type일 때 사용된다.
  • 프로토콜을 사용한 다형성을 구현하기 위해 사용된다.
  • 내부 동작이 복잡하긴 하나 성능은 클래스를 사용하는 것과 비슷하다.
    • 둘 모두 초기화할 때 힙에 공간을 할당한다.
    • 둘 모두 동적 메소드 디스패치를 한다.
      • 클래스는 V-Table을 사용한다.
      • 프로토콜은 PWT를 사용한다.

작은 사이즈(3워드 이하)의 Protocol Type

구분 내용
메모리 할당 스택 (Existential Container)
참조 카운팅 발생 여부 false
메소드 디스패치 동적 (PWT)

큰 사이즈(3워드 초과)의 Protocol Type

구분 내용
메모리 할당 스택 (Existential Container, 실제 데이터는 힙)
참조 카운팅 발생 여부 false
메소드 디스패치 동적 (PWT)

복사가 발생할 때 값 저장을 위한 새로운 힙 공간을 할당한다.

Indirect Storage 사용하여 개선한 큰 사이즈 Protocol Type

구분 내용
메모리 할당 스택 (Existential Container, 실제 데이터는 힙)
참조 카운팅 발생 여부 true (Indirect Storage가 참조 타입)
메소드 디스패치 동적 (PWT)

복사가 발생할 때 새로운 Existential Container를 생성하고 Indirect Storage의 참조를 저장한다.

클래스 수준의 성능을 보여준다.

실험

프로토콜 타입일 때와 해당 타입일 경우의 차이는?
protocol SomeProtocol {
var a: Int { get }
var b: Int { get }
}

struct SomeStruct: SomeProtocol {
var a: Int = 10
var b: Int = 11
}

func foo() {
var someStruct1 = SomeStruct()
var someStruct2: SomeProtocol = SomeStruct()
withUnsafeBytes(of: &someStruct1) { print($0) }
withUnsafeBytes(of: &someStruct2) { print($0) }
}

foo()
UnsafeRawBufferPointer(start: 0x00007ffeefbff4a0, count: 16)
UnsafeRawBufferPointer(start: 0x00007ffeefbff478, count: 40)
someStruct2 : (7FFEEFBFF478) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
B8 84 00 00 01 00 00 00
38 84 00 00 01 00 00 00

someStruct1 : (7FFEEFBFF4A0) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
sil_witness_table hidden SomeStruct: SomeProtocol module main {
method #SomeProtocol.a!getter.1: <Self where Self : SomeProtocol> (Self) -> () -> Int : @$s4main10SomeStructVAA0B8ProtocolA2aDP1aSivgTW // protocol witness for SomeProtocol.a.getter in conformance SomeStruct
method #SomeProtocol.b!getter.1: <Self where Self : SomeProtocol> (Self) -> () -> Int : @$s4main10SomeStructVAA0B8ProtocolA2aDP1bSivgTW // protocol witness for SomeProtocol.b.getter in conformance SomeStruct
}
  • 할당되는 메모리 크기
    • someStruct1은 구조체 타입의 인스턴스를 나타내며 두 개의 Int 타입 프로퍼티를 가지므로 16바이트를 할당받는다.
    • someStruct2는 프로토콜 타입의 인스턴스를 나타내며 Existential Container를 가지므로 40바이트를 할당받는다.
      • 첫 3워드는 Value Buffer로, 가지고 있는 프로퍼티의 값을 나타내고 있다.
        • 첫 8바이트에 10, 다음 8바이트에 11을 할당한다. 마지막 8바이트는 비어 있다.
      • 마지막 2워드는 VWT와 PWT의 주소를 가지고 있다.
  • PWT 생성
    • 컴파일러가 프로토콜을 채택하는 타입마다 PWT를 만들어 내는 것을 확인할 수 있다.
3워드 초과 / 이하 차이로 인한 값 저장 동작 변화
protocol SmallProtocol {
var a: Int { get }
var b: Int { get }
var c: Int { get }
}

protocol LargeProtocol {
var a: Int { get }
var b: Int { get }
var c: Int { get }
var d: Int { get }
}

struct SmallStruct: SmallProtocol {
var a: Int = 10
var b: Int = 11
var c: Int = 12
}

struct LargeStruct: LargeProtocol {
var a: Int = 10
var b: Int = 11
var c: Int = 12
var d: Int = 13
}

func foo() {
var smallStruct: SmallProtocol = SmallStruct()
var largeStruct: LargeProtocol = LargeStruct()
withUnsafeBytes(of: &smallStruct) { print($0) }
withUnsafeBytes(of: &largeStruct) { print($0) }
}

foo()
UnsafeRawBufferPointer(start: 0x00007ffeefbff488, count: 40)
UnsafeRawBufferPointer(start: 0x00007ffeefbff460, count: 40)
largeStruct : (7FFEEFBFF460) : 60 D1 54 00 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
90 85 00 00 01 00 00 00
88 84 00 00 01 00 00 00

smallStruct : (7FFEEFBFF488) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
10 85 00 00 01 00 00 00
68 84 00 00 01 00 00 00
(10054D160) : 50 84 00 00 01 00 00 00
02 00 00 00 00 00 00 00
0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
0D 00 00 00 00 00 00 00
  • 할당되는 메모리 크기
    • 둘 모두 프로토콜 타입이므로 40바이트를 할당받는다.
  • 3워드 초과 / 이하로 인한 차이
    • smallStruct는 3워드의 크기를 갖는 프로토콜을 채택하여, Existential Container의 Value Buffer에 모든 값이 저장되는 것을 확인할 수 있다.
    • largeStruct는 4워드의 크기를 갖는 프로토콜을 채택하여, 별도의 힙 공간을 할당하여 그 곳에 모든 값을 저장하고, Existential Container에 할당된 공간의 주소를 갖는 포인터를 저장하는 것을 확인할 수 있다.
3워드 초과 / 이하 차이로 인한 Copy 동작 변화
3워드 이하
protocol SmallProtocol {
var a: Int { get }
var b: Int { get }
var c: Int { get }
}

struct SmallStruct: SmallProtocol {
var a: Int = 10
var b: Int = 11
var c: Int = 12
}

func foo() {
var bar1: SmallProtocol = SmallStruct()
var bar2 = bar1
// breakpoint 1
bar2.a = 11
// breakpoint 2
withUnsafeBytes(of: &bar1) { print($0) }
withUnsafeBytes(of: &bar2) { print($0) }
}

foo()
// breakpoint 1
bar2 : (7FFEEFBFF460) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
28 85 00 00 01 00 00 00
50 84 00 00 01 00 00 00

bar1 : (7FFEEFBFF488) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
28 85 00 00 01 00 00 00
50 84 00 00 01 00 00 00
// breakpoint 2
bar2 : (7FFEEFBFF460) : 0B 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
28 85 00 00 01 00 00 00
50 84 00 00 01 00 00 00

bar1 : (7FFEEFBFF488) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
28 85 00 00 01 00 00 00
50 84 00 00 01 00 00 00

3워드 이하인 경우 값 전체가 그대로 복사되며, 값이 분리된다.

3워드 초과
protocol LargeProtocol {
var a: Int { get set }
var b: Int { get set }
var c: Int { get set }
var d: Int { get set }
}

struct LargeStruct: LargeProtocol {
var a: Int = 10
var b: Int = 11
var c: Int = 12
var d: Int = 13
}

func foo() {
var bar1: LargeProtocol = LargeStruct()
var bar2 = bar1
// breakpoint 1
bar2.a = 11
// breakpoint 2
withUnsafeBytes(of: &bar1) { print($0) }
withUnsafeBytes(of: &bar2) { print($0) }
}

foo()
// breakpoint 1

bar2 : (7FFEEFBFF460) : 90 B2 B3 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
48 85 00 00 01 00 00 00
80 84 00 00 01 00 00 00

bar1 : (7FFEEFBFF488) : 90 B2 B3 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
48 85 00 00 01 00 00 00
80 84 00 00 01 00 00 00
(103B3B290) : 0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
0D 00 00 00 00 00 00 00
// breakpoint 2

bar2 : (7FFEEFBFF460) : 70 B8 B3 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
48 85 00 00 01 00 00 00
80 84 00 00 01 00 00 00

bar1 : (7FFEEFBFF488) : 90 B2 B3 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
48 85 00 00 01 00 00 00
80 84 00 00 01 00 00 00
(103B3B290) : 68 84 00 00 01 00 00 00
02 00 00 00 00 00 00 00
0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
0D 00 00 00 00 00 00 00

(103B3B870) : 78 19 B3 94 FF 7F 00 00
02 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
0D 00 00 00 00 00 00 00

3워드 초과인 경우 쓰기가 발생하기 전에는 동일한 힙 공간을 가리키지만, 쓰기가 발생하면 별도의 힙 공간을 할당한 후 그 곳에 값을 복사하여 Value Semantics를 구현한다.

VWT와 PWT는 힙에 위치하는가?
  • DATA 섹션인 것으로 나오지만… (TODO: 힙에 있는가 데이터에 있는가?)
Copy-on-Write가 발생하는가?
protocol SomeProtocol {
var array: [Int] { get set }
}

struct SomeStruct: SomeProtocol {
var array: [Int] = [10, 11, 12]
}

func foo() {
var bar1: SomeProtocol = SomeStruct()
var bar2 = bar1
// breakpoint 1
bar2.array[0] = 9
// breakpoint 2
}

foo()
// breakpoint 1

bar2 : (7FFEEFBFF468) : 50 63 8D 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
50 74 00 00 01 00 00 00
28 74 00 00 01 00 00 00

bar1 : (7FFEEFBFF490) : 50 63 8D 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
50 74 00 00 01 00 00 00
28 74 00 00 01 00 00 00
(1038D6350) : A0 19 B3 94 FF 7F 00 00
02 00 00 00 02 00 00 00
03 00 00 00 00 00 00 00
06 00 00 00 00 00 00 00
0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
// breakpoint 2

bar2 : (7FFEEFBFF468) : D0 C2 60 00 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
50 74 00 00 01 00 00 00
28 74 00 00 01 00 00 00

bar1 : (7FFEEFBFF490) : 50 63 8D 03 01 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
50 74 00 00 01 00 00 00
28 74 00 00 01 00 00 00
(10060C2D0) : A0 19 B3 94 FF 7F 00 00 
02 00 00 00 00 00 00 00
03 00 00 00 00 00 00 00
08 00 00 00 00 00 00 00
09 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00

(1038D6350) : A0 19 B3 94 FF 7F 00 00
02 00 00 00 02 00 00 00
03 00 00 00 00 00 00 00
06 00 00 00 00 00 00 00
0A 00 00 00 00 00 00 00
0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00
  • breakpoint 1
    • bar1bar2는 프로토콜 타입이므로 각각의 Existential Container를 갖는다.
    • Array의 Indirect Storage의 주소가 복사된다. 내부 데이터가 가리키는 힙의 공간은 2의 참조 카운트를 갖는다.
    • 같은 VWT와 PWT를 참조한다.
  • breakpoint 2
    • bar2에 대하여 쓰기가 일어났다. 힙에 새로운 공간을 할당하여 복사한다.
    • 각각의 Existential Container가 서로 다른 힙 공간을 가리키게 된다. 각각의 내부 데이터가 가리키는 힙의 공간은 1의 참조 카운트를 갖는다.
    • 같은 VWT와 PWT를 참조한다.

Array는 내부적으로 Copy-on-Write를 구현해 두어 이러한 동작을 확인할 수있다.

하지만 개발자가 Indirect Storage를 직접 구현한다면 위에서 설명한 기법을 사용하여 Copy-on-Write를 직접 구현해 주어야 한다.

큰 크기의 Protocol Type vs. Indirect Storage
  • 큰 크기의 Protocol Type
    • 내부 데이터를 힙에 저장
    • 참조 카운팅을 하지 않음
    • Copy-on-Write가 구현되어 있음
  • Indirect Storage
    • 내부 데이터를 힙에 저장
    • 참조 카운팅을 함
    • Value Semantics가 필요한 경우 Copy-on-Write를 직접 구현해야 함

Generic Type

protocol Drawable { func draw() }
struct Point: Drawable { func draw() { ... } }
struct Line: Drawable { func draw() { ... } }

func draw<T: Drawable>(_ drawable: T) {
drawable.draw()
}

draw(Point())
draw(Line())
  • 제네릭 타입 T는 Drawable 프로토콜을 채택하는 타입이어야 한다.
  • Protocol Type과 동일하게 Existential Container를 사용하며, 동작도 비슷하다.
  • 하지만 메소드 내에서 T의 실제 타입이 결정되고 바뀌지 않는다. : 정적 다형성
  • 그러므로 컴파일러가 이를 최적화할 수 있다.
func draw(_ point: Point) {
point.draw()
}

func draw(_ line: Line) {
line.draw()
}

draw(Point())
draw(Line())
  • 컴파일러는 위와 같은 코드로 최적화할 수 있다. : Generic 특수화
  • 이렇게 되면 Existential Container를 사용하지 않아도 된다.
  • 정적 메소드 디스패치가 되어 메소드 인라이닝 등 컴파일 타임 최적화가 가능하게 된다.

실험

정적 다형성이 구현된 것을 확인해볼 수 있을까?
protocol SomeProtocol {
var a: Int { get }
}

struct SomeStruct1: SomeProtocol {
var a: Int = 10
}

struct SomeStruct2: SomeProtocol {
var a: Int = 11
var b: Int = 12
}


func bar<T: SomeProtocol>(_ bar: T) {
var bar = bar
print(type(of: bar))
withUnsafeBytes(of: &bar) { print($0) }
}

func foo() {
bar(SomeStruct1())
bar(SomeStruct2())
}

foo()
SomeStruct1
UnsafeRawBufferPointer(start: 0x00007ffeefbff380, count: 8)
SomeStruct2
UnsafeRawBufferPointer(start: 0x00007ffeefbff380, count: 16)
(7FFEEFBFF380) : 0A 00 00 00 00 00 00 00
(7FFEEFBFF380) : 0B 00 00 00 00 00 00 00
0C 00 00 00 00 00 00 00

메소드 내에서 제네릭 타입은 특정 타입으로 결정되고 변하지 않는다.

특정 타입으로 결정되므로, 위의 코드에서는 구조체의 크기만큼 메모리에 할당된다.

특수화되지 않은 Generics (작은 크기의 Protocol Type)

protocol SmallProtocol {
var a: Int { get }
}

struct SmallStruct {
var a: Int = 10
}

func foo<T: SmallProtocol>(_ bar: T) { ... }
구분 내용
메모리 할당 스택 (Existential Container)
참조 카운팅 발생 여부 false
메소드 디스패치 동적 (PWT)

제네릭 타입이 작은 크기의 Protocol Type으로 제한되며, 특수화되지 않았다면, 작은 크기의 Protocol Type을 다루는 것처럼 동작한다.

특수화되지 않은 Generics (큰 크기의 Protocol Type)

protocol LargeProtocol {
var a: Int { get }
...
}

struct LargeStruct: {
var a: Int = 10
...
}

func foo<T: LargeProtocol>(_ bar: T) { ... }
구분 내용
메모리 할당 스택 (Existential Container, 실제 데이터는 힙)
참조 카운팅 발생 여부 false
메소드 디스패치 동적 (PWT)

제네릭 타입이 큰 크기의 Protocol Type으로 제한되며, 특수화되지 않았다면, 큰 크기의 Protocol Type을 다루는 것처럼 동작한다.

특수화된 Generic Type (Struct)

구분 내용
메모리 할당 스택
참조 카운팅 발생 여부 false
메소드 디스패치 정적

제네릭 타입을 요구하는 인자에 구조체 타입의 인스턴스를 넘기며, 특수화되었다면, 구조체를 다루는 것처럼 동작한다.

특수화된 Generic Type (Class)

구분 내용
메모리 할당
참조 카운팅 발생 여부 true
메소드 디스패치 동적 (V-Table)

제네릭 타입을 요구하는 인자에 클래스 타입의 인스턴스를 넘기며, 특수화되었다면, 클래스를 다루는 것처럼 동작한다.

정리

  • 정적 다형성 (Static Polymorphism)
    • 컴파일 시점에 호출하는 곳마다 타입이 정해져 있음
    • 런타임에 변경되지 않음
    • 특수화 (Specialization) 가능

마무리

  • Swift의 성능은 Objective-C와 비교하여 많이 향상되었다.
  • Value Type과 Protocol Type 등의 성격을 고려해야 한다. 성능 최적화를 고려하는 경우 이 내용들을 잘 써먹을 수 있을 것이다.
    • 일반적인 경우에는 고려할 필요가 없다.
    • 렌더링 관련 로직 등 반복적으로 매우 빈번하게 호출되는 경우 / 서버 환경에서의 대용량 데이터 처리의 경우 이 내용을 써먹을 수 있을 것이다.

추상화 기법의 선택

  • Struct : Value Semantics가 맞는 부분
  • Class : Identity가 맞는 부분 / 객체 지향 프로그래밍 / Objective-C 호환
  • Generics : 정적 다형성으로 처리 가능한 경우
  • Protocol : 동적 다형성이 필요한 경우

고려할 수 있는 성능 최적화 기법들

  • Struct에 참조 타입 프로퍼티가 많은 경우
    • Enum, Struct 등 Value Type으로 대체할 수 있는 방법 찾기
    • 이외에 참조 카운팅을 줄일 수 있는 방법을 찾기
  • Protocol Type을 사용하는데 대상이 크기가 큰 Struct인 경우
    • Indirect Storage를 사용하여 Struct 구조 변경하기
    • Mutable해야 한다면 Copy-on-Write 구현하기
  • 동적 메소드 디스패치를 정적으로 할 수 있게 하기
    • 가능하면 final, private 선언을 할 수 있도록 하기
    • dynamic 사용하지 않기
    • Objective-C 연동 최소화하기
    • 릴리즈 빌드에 WMO 옵션 활성화 고려하기

마무리

  • 모든 경우에 반드시 적용해야 하는 것은 아니지만, 배경을 아는 것과 옳은 방향으로 향하는 것은 중요하다.

더 알아볼 것들

Swift의 String

  • Swift의 String은 64비트 시스템에서 16바이트로 할당된다.
    • String에 15바이트 이하의 스트링 리터럴을 할당하는 경우
      • 스택에 값이 바로 들어감
      • TEXT 섹션에서도 할당한 스트링 리터럴을 확인할 수 있음
      • 컴파일러 최적화 : 15바이트 이하인 경우 TEXT 섹션에 스트링 리터럴 값을 저장하는 것에 더하여 스택에 바로 값을 두어 빠르게 사용할 수 있도록 함
      • Swift 4.2의 64비트에서의 small-string representation 링크
    • String에 15바이트 초과의 스트링 리터럴을 할당하는 경우
      • 뒤의 8바이트에서 맨 끝의 1바이트를 제외한 7바이트에서 스트링 리터럴이 저장된 주소를 확인할 수 있음
      • TEXT 섹션(TEXT.cstring)
      • Cocoa Internals : 스트링 리터럴 값은 TEXT 영역에 저장된다.
      • 15바이트 초과인 경우 TEXT 섹션을 가리키는 포인터를 저장하고 그것을 참조하여 사용할 수 있게 한다.

  • Value 타입과 Reference 타입의 구분은 저장된 영역의 구분이 아닌, 오퍼랜드를 주소로 해석할 것인가 값으로 해석할 것인가의 차이일 뿐이다.
    • 이외에 Reference Type인 경우 64비트 시스템에서 주소를 저장하기 위한 8바이트의 공간을 할당받음
  • String’s ABI and UTF-8 : 2018년 11월, Swift 5 관련 내용
    • 네이티브 스위프트 문자열은 아스키이든 UTF-16이든에 상관 없이 UTF-8로 저장됨
    • UTF-8은 1바이트 유니코드 인코딩을 뜻하며 C 호환, 시스템 프로그래밍, 서버 사이드 프로그래밍, 스크립팅, 클라이언트 사이드 프로그래밍 등에 선호되는 포맷임
    • 아스키와 유니코드 문자열 표현을 위한 스토리지를 통합하여 많은 성능 이점을 취할 수 있음
      • C 호환 : C와의 호환에서 비용이 없음 (할당, 인코딩 변환 등이 없음)
      • 디코딩 : 문자열을 구성하는 유니코드 스칼라 값을 디코딩하는 것이 능률적이게 됨
        • 한자의 경우 UTF-16과 비교하여 UTF-8의 디코딩 성능에 대한 가장 나쁜 케이스. UTF-16은 코드 유닛에 스칼라 값을 직접 저장하는 반면 UTF-8은 멀티바이트 인코딩 시퀀스를 저장한다.
      • 작은 UTF-8 문자열
        • Swift 4.2에서 64비트 플랫폼에서의 작은 문자열 표현을 도입함
          • 15개의 아스키 코드 단위까지는 직접적으로 String 구조체에 저장됨 (할당이나 메모리 관리를 필요로 하지 않음)
        • 이제 UTF-8 표현과 통합되었으므로 15개의 UTF-8 단위까지 위와 같은 동작을 취할 수 있음
  • Contiguous Strings
  • UTF-8 String