본문 바로가기
Dev/WWDC 정리

[WWDC16] Understanding Swift Performance - 1부

by Mintta 2023. 5. 5.
이전에 올렸던 글이 사진과 글이 몇개 누락된 채로 올라갔던 걸 발견해서 조금 더 첨부해서 다시 올리고 원래글은 비공개로 돌립니다😐

Understanding Swift Performance 1부

Heap이 Stack보다 동적이지만 효율성이 떨어진다

Heap 자료구조를 사용하면서 힙을 검색하여 적절한 크기의 사용하지 않는 블록을 찾고 할당 해제 후 메모리를 적절한 위치에 다시 삽입하는 과정을 겪게 되는데, 이것이 Heap allocation(힙 할당)의 메인 cost가 되는 것이 아니다.

여러 스레드가 동시에 힙에 메모리를 할당할 수 있으므로 힙은 잠그거나, 또는 기타 동기화 메커니즘을 사용하여 무결성을 보호해야한다는 것이 사실 main cost라고 할 수 있다.

하지만 사람이 직접 이 모든걸 관리하면서 한다면 퍼포먼스가 굉장히 떨어진다. 이런 메모리할당과 관련해서 Swift가 우리를 위해 어떤 작업을 해주는지 살펴보자

Struct

import Foundation

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

let point1 = Point(x: 0, y: 0)
var point2 = point1

point2.x = 5

point1 // Point(x: 0, y: 0)
point2 // Point(x: 5.0, y: 0)

2 words 할당 (word: 16bit)

함수에 들어가자마자 아직 어떠한 코드도 실행시키기도 이전에 stack에 point1 인스턴스와 point2 인스턴스에 대한 공간을 할당했다.

우리가 point1을 point2에 할당할 때도 그저 copy되는 것이고 point2는 Point1과는 독립적인 인스턴스이다. 이건 value semantics로 알려져있다.

함수에 대한 모든 실행을 마친다면 stack pointer를 맨 처음 해당 함수로 들어왔던 곳으로 pointer를 increment해놓음으로써 메모리를 할당 해제할 수 있다.

Class

import Foundation

class Point {
    var x, y: Double

    init(x: Double, y: Double) {
        self.x = x
        self.y = y
    }

    func draw() {}
}

let point1 = Point(x: 0, y: 0)
var point2 = point1

point2.x = 5

point1 // Point(x: 5.0, y: 0)
point2 // Point(x: 5.0, y: 0)

4 words 할당 (word: 16bit)

 

Stack에는 Point1과 Point2에 대한 reference가 저장된다. Heap에 할당될 메모리에 대한 참조이다.

let point1 = Point(x: 0, y: 0) 이 코드를 실행하면서 Swift는 힙을 Lock하고 적절한 사이즈의 사용되지 않는 빈공간의 메모리을 찾아서 x와 y값을 초기화하고 Point1에 대한 Referenence를 해당 메모리에 대한 주소로 초기화할 수 있다.

Note: Swift는 Point클래스를 위해 4words만큼 메모리를 할당했는데(Stack은 2 words), 그 이유는 Point가 클래스이고, 추가로 x와 y가 저장되어 있기 때문이다.
(클래스가 왜 2 words더 할당받았는지, x와 y값 위에 2words가 뭘로 저장되어있는지는 뒤에 나온다 ! )

point2 = point1 의 코드에는 레퍼런스가 복사되는 것이기 때문에 동일하게 값이 바뀌게 된다. 의도하지 않은 상태 공유가 발생할 수 있다는 점을 인지해야한다.

 

Allocation

 

위에서 예제를 통해 알 수 있듯이, Class는 Heap에 저장되기 때문에 Heap allocation이 필요하고 따라서 struct보다 비용이 더 많이 든다.

Class는 힙에 저장되고 reference semantics를 가지고 있기 때문에 Identitiy나 indirect storage와 같은 곳에 효과적이다. 하지만 추상화에 이러한 특성이 필요하지 않다면, struct를 사용한다면 더 효과적일 것이다.

그리고 구조체는 클래스와 같이 의도하지 않은 상태 공유가 발생하지 않는다.

 

Swift 코드의 성능을 향상시키기 위해 이를 적용할 수 있는 방법을 알아보자.

카카오톡 말풍선과 같은 뷰를 만드는 코드라고 생각해보자.

해당 말풍선은 매우 빨리 동작해야하기 때문에 캐싱을 하기로 하였다. 아래처럼

import UIKit

enum Color { case blue, green , gray }
enum Orientation { case left, right }
enum Tail { case none, tail, bubble }

var cache = [String: UIImage] = [:]

func makeBalloon(_ color: Color, orientation: Orientation, tail: Tail) -> UIImage {
    let key = "\(color):\(orientation):\(tail)"
    if let image = cache[key] {
        return image
    }
    // ...
}

위의 코드에는 몇가지 마음에 안드는 점들이 존재한다.

첫번째로, key값으로 어떠한 String값을 넣을수가 있다. 안전장치가 없다는 점과 String은 실제로 Characters들을 힙에 간접적으로 저장하기 때문에, 이 makeBalloon 기능을 호출할 때마다 캐시 히트가 있더라도 힙 할당이 발생한다는 문제가 있다.

struct Attributes: Hashable { // Dictionary의 키로 사용하고자 한다면
    var color: Color
    var orientation: Orientation
    var tail: Tail
}

위와 같은 struct을 이용해서 Key값을 만들 수 있다. Swift에서 struct는 일급 시민이기 때문에, struct를 딕셔너리의 key값으로 사용할 수 있기 때문에 String으로 key값을 다루는 것보다 이 편이 훨씬 안전하다.

...

struct Attributes {
    var color: Color
    var orientation: Orientation
    var tail: Tail
}

var cache = [String: UIImage] = [:]

func makeBalloon(_ color: Color, orientation: Orientation, tail: Tail) -> UIImage {
    let key = Attributes(color: color, orientation: orientation, tail: tail)
    if let image = cache[key] {
        return image
    }
    // ...
}

이렇게 코드를 바꾼다면 만약 캐시 히트가 생긴다면 추가로 메모리 할당이 일어나지 않는다.

왜나하면 struct는 stack에 저장되기 때문에 heap allocation을 필요로 하지 않기 때문이다. 그렇기 때문에 훨씬 안전하고 빠른 성능을 보여줄 것이다.

Reference Counting

Swift는 힙에 할당된 메모리를 할당 해제할 때 이것이 안전한지 어떻게 알 수 있을까??🤔

정답은 Swift가 힙에 있는 인스턴스에 대한 총 Reference Counting(참조 수, 이하 RC라 하겠다)를 세는 것이다. 그리고 그것을 인스턴스 자체에 유지한다.

RC가 0이 된다면, Swift는 아무도 해당 인스턴스를 가리키고 있지 않다는 것을 알고 이제 메모리에서 할당을 해제해도 안전하다는 알 수 있게 된다.

RC와 관련하여 염두에 두어야 할 핵심 사항은 이것은 정말 빈번한 작업이며 실제로 정수를 증감하는 것 이상의 것이 있다는 것이다.

 

중요한 것은 힙 할당과 마찬가지로 여러 스레드의 모든 힙 인스턴스에 동시에 참조를 추가하거나 제거할 수 있기 때문에 고려해야 할 스레드 안전성이 있다는 것이다.

실제로 RC를 atomically 증가 및 감소시켜야 한다. 그리고 RC작업의 빈도 때문에 이 비용은 증가할 수 있다.

import Foundation

class Point {
    var refCount: Int = 1
    var x, y: Double

    init(x: Double, y: Double) {
        self.x = x
        self.y = y
    }

    func draw() {}
}

func retain(_ point: Point) {
    // Swift가 atomic하게 refCount 1증가시키기
}

func release(_ Point: Point) {
    // atomic하게 refCount 1감소시키기
}

let point1 = Point(x: 0, y: 0) // Point의 RC는 1로 초기화되면서 시작한다.
let point2 = point1 // 여기서 point1의 RC는 2가 된다
retain(point2) // 

point2.x = 5

point1
release(point1) // point1의 참조가 끝났기 때문에 release
point2
release(point2) // point2의 참조가 끝났기 때문에 release

point1의 사용이 끝나면 Swift가 atomic하게 RC를 감소시킨다. 그리고 point2도 동일하게 진행

이 시점에서 point 인스턴스를 사용하는 참조가 더 이상 없으므로 Swift는 힙을 잠그고 메모리 블럭을 반환하는 것이 안전하다는 것을 알게 된다.

import Foundation

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

let point1 = Point(x: 0, y: 0)
var point2 = point1

point2.x = 5

point1 // Point(x: 0, y: 0)
point2 // Point(x: 5.0, y: 0)

그렇다면 struct는 어떨까?? RC와 관련이 있을까?? (아니요)

어떠한 힙 메모리 할당도 포함되지 않기 때문에 RC와는 상관이 없다.

그렇다면 좀 더 복잡한 struct는 어떨까?🤔

struct Label {
    var text: String // 실제로는 contents들을 힙에 저장한다. -> RC
    var font: UIFont // class -> RC
    func draw() {}
}

let label1 = Label(text: "HI", font: font)
let label2 = label1

  • text: String → RC필요 (String은 앞서 말했듯이 String의 Characters들을 힙에 저장한다)
  • font: UIFont → Class이기 때문에 RC필요

let label2 = label1

  • copy가 일어나게 되면(struct), 사실 2개의 reference(text, font)를 추가하게 된다😮
struct Label {
    var text: String // 실제로는 contents들을 힙에 저장한다. -> RC
    var font: UIFont // class -> RC
    func draw() {}
}

let label1 = Label(text: "HI", font: font)
let label2 = label1
retain(label2.text._storage)
retain(label2.font)

label1
release(label1.text._storage)
release(label1.font)
label2
release(label2.text._storage)
release(label2.font)

Summary

Class

  • Class는 힙에 할당되기 때문에, 힙 할당의 생명주기를 관리해야하기 때문에 RC를 사용한다. 이것은 비교적 자주일어나고 RC를 증가 감소시키기 위해서는 atomically하게 수행해야하기 때문에 중대한 작업이다. 
  • 따라서 struct에 비해 비용이 더 들게 된다.

Struct

  • struct는 힙에 할당되지 않는다. 그렇기 때문에 RC도 필요없다. 따라서 훨씬 적은 비용이 들고 좋은 성능을 보일 수 있다.

Struct containing many references

  • 하지만 위에서 본 복잡한 struct일 경우(class를 포함하고 있는 struct)에는 counting overhead가 2배로 발생하는 것을 확인할 수 있었다. 따라서 N개의 class들을 포함하고 있다면 그만큼 RC의 비용은 늘어날 것이기 때문에 위와 같은 지표가 나타난다.

예시

카카오톡에서 텍스트 말풍선뿐만 아니라 이미지를 사용자들끼리 주고 받고 싶은 상황.

struct Attachment {
    let fileURL: URL
    let uuid: String // 무작위로 생성된 128bit 식별자
    let mimeType: String // PNG, JPEG, GIF

    init?(fileURL: URL, uuid: String, mimeType: String) {
        guard mimeType.isMimeType
        else { return nil }

        self.fileURL = fileURL
        self.uuid = uuid
        self.mimeType = mimeType
    }
}

  • 많은 reference counting 발생
  • uuid는 128비트의 생성자인데, String으로 하게 되면 어떠한 값도 넣을 수 있게 되기 때문에 안전장치가 없다

struct UUID

struct Attachment {
    let fileURL: URL
    let uuid: UUID // 👍 무작위로 생성된 128bit 식별자
    let mimeType: String // JPG, JPEG

    init?(fileURL: URL, uuid: UUID, mimeType: String) {
        guard mimeType.isMimeType
        else { return nil }

        self.fileURL = fileURL
        self.uuid = uuid
        self.mimeType = mimeType
    }
}
  • uuid: Stringuuid: UUID
  • UUID 타입은 struct이다. 따라서 uuid에 대한 counting overhead를 없앨 수 있다.
  • 또한, 아무값이나 넣을 수 없게 되어서 String에는 없던 안전장치가 생겼다.
struct Attachment {
    let fileURL: URL
    let uuid: UUID // 무작위로 생성된 128bit 식별자
    let mimeType: String // JPG, JPEG

    init?(fileURL: URL, uuid: UUID, mimeType: String) {
        guard mimeType.isMimeType
        else { return nil }

        self.fileURL = fileURL
        self.uuid = uuid
        self.mimeType = mimeType
    }
}

extension String {
    var isMimeType: Bool {
        switch self {
        case "image/jpeg":
            return true
        case "image/png":
            return true
        case "image/gif":
            return true
        default:
            return false
        }
    }
}
struct Attachment {
    let fileURL: URL
    let uuid: UUID // 무작위로 생성된 128bit 식별자
    let mimeType: MimeType // JPG, JPEG

    init?(fileURL: URL, uuid: UUID, mimeType: String) {
        guard let mimeType = MimeType(rawValue: mimeType)
        else { return nil }

        self.fileURL = fileURL
        self.uuid = uuid
        self.mimeType = mimeType
    }
}

enum MimeType {
    init?(rawValue: String) {
        switch rawValue {
        case "image/jpeg":
            self = .jpeg
        case "image/png":
            self = .png
        case "image/gif":
            self = .gif
        default:
            return nil
        }
    }

    case jpeg, png, gif
}
  • Swift의 고정된 것들을 표현하는 훌륭한 추상화 메커니즘인 enum을 활용. enum타입의 MimeType 으로 대체.
  • guard let mimeType = MimeType(rawValue: mimeType) else { return nil }
  • mimeType: StringmimeType: MimeType
  • 안정장치가 생기게 됐고, case들을 힙에 간접적으로 저장할 필요가 없어졌기 때문에 성능이 향상된다.
enum MimeType: String {
    case jpeg = "image/jpeg"
    case png = "image/png"
    case gif = "image/gif"
}

이 코드로 위의 enum을 더 개선시킬 수 있다. 이렇게 작성하면, 더욱 작성하기 쉽고, 더 강력한 type safety를 가지고 동일한 성능을 가진다.

struct Attachment {
    let fileURL: URL
    let uuid: UUID // 👍 무작위로 생성된 128bit 식별자
    let mimeType: MimeType // 👍 JPG, JPEG

    init(fileURL: URL, uuid: UUID, mimeType: MimeType) {
        self.fileURL = fileURL
        self.uuid = uuid
        self.mimeType = mimeType
    }
}

enum MimeType: String {
    case jpeg = "image/jpeg"
    case png = "image/png"
    case gif = "image/gif"
}

let at = Attachment(fileURL: URL(string: "")!, uuid: UUID(uuidString: "")!, mimeType: .jpeg)
  • init에 잘못된 String값이 넘어올일이 없으므로 init?을 init으로 교체해준다.

이제 uuid랑 mimeType을 힙에 할당할 필요가 없어졌기 때문에 RC또한 신경 쓸 필요가 없어졌다.

Method Dispatch

  • static dispatch: 컴파일 타임에 실행할 implementation을 결정할 수 있다
  • dynamic dispatch: 런타임에 implementation을 찾아서 jump한다.

이 자체로는 한단계의 indirection차이일뿐, 위의 힙할당과 reference counting에서 나왔던 thread동기화같은 오버헤드는 없다. 

 

하지만 !!

 

static dispatch를 하면, 컴파일러가 어느 시점에 어떤 implementation을 실행시켜야할지 미리 알고 있기 때문에(가시성), 코드를 inlining을 통해 최적화할 수 있다 ! (dynamic dispatch는 이런거 못함)

 

Inling이 뭔데? (static dispatch)

https://codingmon.tistory.com/39

 

final 키워드를 왜 사용할까?? 정리

final 키워드를 왜 사용할까? 결론부터 결론부터 말하고 차근차근 하나씩 알아보도록 하겠습니다 ! 만약 결론부분만 보시고 이유가 그냥 떠오른다?? 결론만 읽으시고 다아는내용이네.. 하고 뒤로

codingmon.tistory.com

struct Point {
    var x, y: Double

    func draw() {
        // Point.draw impl
    }
}

func drawAPoint(_ param: Point) {
    param.draw()
}

let point = Point(x: 0, y: 0)
drawAPoint(point) /*
컴파일러는 이걸 param.draw()로 replace
이 후에 param.draw()를 Point.draw impl 로 replace.
*/
struct Point {
    var x, y: Double

    func draw() {
        // Point.draw impl
    }
}

func drawAPoint(_ param: Point) {
    param.draw()
}

let point = Point(x: 0, y: 0)
// Point.draw impl

call stack을 쌓았다가 다시 꺼내서 하는 등 call stack overhead 없이 모두 최적화되어 생략된다.

그렇기 때문에 dynamic dispatch보다 성능면에서 훨씬 훌륭하다.

만약 method chaining의 상황을 생각해보면 static dispatch인 경우와 dynamic dispatch인 경우의 차이가 엄청 클 것이다.

dynamic이라면 매 단계 메서드를 결정지어야하기 때문에 멈추게 될 것이다.

 

서브클래싱을 하지 않는 클래스라면 반드시 class앞에 final을 붙여서 static하게 method들을 dispatch할 수 있게 해주자.

 

Dynamic dispatch

  • dynamic dispatch: 컴파일 타임에 어떤 implementation으로 jump해야할지 결정할 수 없다. 런타임에, 실제로 보고 해당 implementation으로 jump하게 된다

dynamic dispaych컴파일러의 가시성을 차단한다. 따라서 inlining같은 최적화를 할 수 없게 만든다.

그렇다면 dynamic dispatch는 왜 필요할까??

Dynamic dispatch는 polymorphism(다형성)과 같은 것을 가능하게 해준다.

class Drawable { func draw() {} }

class Point: Drawable {
    var x, y: Double
    override func draw() {
        print("Point")
    }
}

class Line: Drawable {
    var x1, y1, x2, y2: Double
    override func draw() {
        print("Line")
    }
}

var drawables: [Drawable]
for d in drawables {
    d.draw()
}

다형성을 설명할 때 쓰는 대표격인 예제이다. Drawable 타입의 배열은 동일한 사이즈의 reference를 담고 있다.

해당 배열을 돌면서 draw() 메소드를 호출하면 각각의 인스턴스에 맞게끔 draw() 메소드가 호출된다.

이것을 보아 어째서 컴파일러가 컴파일 타임에 결정하지 못하는지에 대한 직관을 얻을 수 있다. d.draw()는 Point가 될수도, Line이 될 수도 있기 때문에 알 수 있을리가 없다.

그렇다면 어떤 것을 호출할지 어떻게 결정할까??

 

  • 컴파일러가 해당 클래스의 Type information에 대한 pointer를 class의 field로 추가한다.

  • 우리가 draw를 호출할 때, 컴파일러는 draw의 정확한 implementation을 향한 포인터가 담긴 virtual method table loopup을 생성한다.
  • d.draw가 컴파일러가 하는 일에 대해서 번역해보면 d.type.vtable, 즉 Type의 vtable에 가서 draw메서드를 호출하는데 인자로 자기 자신 인스턴스를 넘긴다.

다시 정리하자면..

  • 컴파일러는 해당 클래스의 타입 정보에 대한 포인터를 클래스에 추가하고, 정적 메모리에 저장한다. (Line.Type 메타데이터)
  • draw를 호출할 때 컴파일러가 실제로 우리를 대신하여 생성하는 것은, 실행할 올바른 implement에 대한 포인터를 가지는 타입 및 정적 메모리에 있는 virtual method table(가상함수 테이블) lookup.
    • Virtual Method Table(가상 함수 테이블)??가상 메소드란, 자식 클래스에서 부모 클래스의 메소드를 재정의(ovverride)하여 사용하는 것을 말한다. 이 때 부모 클래스의 메소드가 호출되지 않고, 자식 클래스에서 재정의한 메소드가 호출된다. 이러한 동작을 구현하기 위해 Swift는 가상 메소드 테이블을 사용한다.이러한 방식으로 Swift는 다형성을 지원하며, 객체 지향 프로그래밍의 장점 중 하나인 유연성과 확장성을 제공한다.
    • 가상 메소드 테이블은 객체의 클래스마다 하나씩 생성된다. 각각의 가상 메소드 테이블은 클래스에 정의된 가상 메소드의 포인터들을 저장하고 있다. 객체가 생성될 때, 해당 객체의 클래스의 가상 메소드 테이블을 참조하며, 가상 메소드를 호출할 때에는 해당 클래스의 가상 메소드 테이블에서 메소드의 위치를 찾아서 실행한다.
    • Swift는 가상 함수 테이블을 통해서 가상 메소드(virtual method)를 관리한다.
  • (어떤 implement를 실행시켜야할지 Line.Type이 알고 있으며 이를 컴파일러가 조회)
  • 만약 d.draw()를 컴파일러가 우리를 대신하여 수행하는 작업으로 변경하면, 컴파일러가 실행시킬 올바른 draw implementation을 찾기 위해 컴파일러가 virtual method table을 살펴본다는 것을 알 수 있다.
  • 그런 다음 실제 인스턴스를 암묵적 self-parameter로 넘긴다.

classdefault로 그들의 메서드들을 dynamic dispatch한다. 이것 자체로는 크게 다르지 않지만, method chaining 등과 같은 경우에 inlining같은 최적화를 못하게 한다.

모든 class들이 dynamic dispatch가 필요한 것은 아니다. 그렇기 때문에 서브 클래스로 둘 의도가 전혀 없다면 final class로 선언하게 되면 컴파일러는 static하게 메서드들을 dispatch할 것이다. 또한, 컴파일러가 앱에서 클래스를 서브클래스로 두지 않을 것임을 추론하고 증명할 수 있다면 dynamic dispatch를 우리를 대신해서 static dispatch로 바꿔준다. 

이 작업이 어떻게 이뤄지는 지 더 듣고 싶다면 해당 링크 참고.

앞으로 Swift code를 보거나 쓸 때 항상 생각할 점

  • 이 인스턴스가 stack, heap 어디에 할당될까???
  • 이 인스턴스를 주고받을 때 얼만큼의 reference counting overhead를 내가 일으키게 될까???
  • 내가 이 인스턴스에서 메서드들을 호출할 때, statically dispatch될까?? 아니면 dynamically dispatch될까?? 필요하지 않은 dynamism을 쓴다면, 그것은 우리의 코드의 성능을 저하시킬 것이다.

 

근데 한가지 의문점이 남는다..

 

“struct를 이용해서 다형성을 어떻게 구현할 수 있나요??”🤔

 

그에 대한 해답은 Protocol Oriented Programming(POP)이다.

댓글