[WWDC16] - Understanding Swift Performance 1편

어흥·2024년 6월 26일

WWDC

목록 보기
1/3

WWDC16 - Understanding Swift Performance

Overview

Swift에는 다양한 Abstraction mechanism이 있고 다양항 일급 객체가 있다. 어떻게 알맞은 Abstraction Mechanism 선택할 수 있을까?

바로 각각 추상화 메케니즘이 모델링(성능)에 미치는 영향을 고려해야한다. 영향을 이해하려면 기본 구현을 이해하는 것이 중요하다 !

Dimensions of Performance

추상화 메케니즘을 선택할 때 질문해야하는 것

  1. 인스턴스가 어디에 할당되는지? 스택 or 힙
  2. 인스턴스 전달될 때 참조 계산 오버헤드가 얼마나 되는가?
  3. 인스턴스가 메서드를 호출할 때 어떠한 디스패치 방식? static or dynamic

불필요한 오버헤드나 디스패치를 통해 성능을 떨어지는 문제가 발생할 수도 …

Allocation

Stack

스택은 매우 간편한 메모리 공간. (스택의 끝을 가르키는 주소를 스택포인터라고 함 )

스택은 메모리 할당, 해제가 매우 빠르다. 바로 스택포인터를 활용하기 때문!

  • 함수를 호출할 때, 스택 포인터를 간단하게 줄이는 것만으로 필요한 메모리 할당 가능
  • 함수 실행 끝나면, 스택 포인터를 전의 위치로 다시 증가시켜 메모리 할당 해제

→ 이렇게 정수를 할당하기만 하면 되므로 간단하고 매우 빠름

스택 (빠름, 효율적, 정적) vs 힙(비효율적, 동적)

Heap

동적 생명주기로 메모리를 할당할 수 있는 메모리 공간

발생하는 cost

  1. 좀 더 복잡한 data structure

  2. 메모리 할당/해제 과정

    • 힙 데이터 구조를 검색하여 적절한 크기의 사용되지 않은 블록을 찾아야 함
    • 할당을 해제하려면 해당 메모리를 적절한 위치에 다시 삽입해야 함

    → 스택에 비해 더 많은 절차가 필요 (비용 발생 )

  3. Thread safety overhead

    여러 스레드가 힙에 메모리 할당 가능하므로 locking, synchronization 매커니즘을 사용하여 무결성을 지켜야 함

비교

Allocation - struct

  • 스택에 메모리 할당 Point는 구조체이므로 x, y 속성은 스택에 일렬로 저장
    • point1, point2 독립적인 인스턴스
  • 함수 실행을 마치면 스택 포인터 다시 증가하여 point1, point2 메모리 할당 간단히 해제
// Allocation
// Struct
struct Point {
	var x, y: Double
	func draw() {}
}

let point1 = Point(x: 0, y: 0)
var point2 = point1
point2.x = 5
// use `point1`
// use `point2

Allocation - Class

  1. 스택에 메모리 할당

    속성을 저장하는 것이 아닌 point1, point2에 대한 참조값을 저장

  2. heap에 메모리 할당 (실제 값 속성 저장)

    heap을 lock하고 데이터 구조에서 적절한 크기의 사용되지 않은 메모리 블록 검색

  3. x, y를 각각 0으로 메모리 초기화하고 힙에 있는 해당 메모리 주소를 사용하여 point1 참조 초기화

// Allocation
// Class
class Point {
	var x, y: Double
	func draw() {}
}

let point1 = Point(x: 0, y: 0)
var point2 = point1
point2.x = 5
// use `point1`
// use `point2


구조체(2개)와 달리 4개 words 메모리 공간 할당

저장공간 개수가 차이나는 이유 → 포인트가 클래스이기 때문 x, y 외에도 저장할 것 존재

point2가 point1 copy할 때 참조값만 복사하고 동일한 인스턴스를 참조한다. → 한 인스턴스에서 값이 변경되면 모두 공유, reference semantics

  1. Swift가 우리 대신 메모리 할당 해제, 힙을 잠그고 사용되지 않은 블록을 재위치
  2. stack pop

    heap에 할당해야하기 때문에 cost up!
    class에서 얻을 수 있는 이점을 사용하지 않는다면 struct 사용하는 것이 성능면에서 유리

적용


  • 파란색 물풍선은 .blue, .right, .tail이고 회색은 .gray, .left, .bubble
  • 해당 이미지를 자주 사용하므로 cache에 저장해서 사용 (이미지 2번 생성 필요 x)
// Modeling Techniques: Allocation
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] {
				retrun image
		}
		...
}

성능 저하 요소

  • key : string type으로 되어 있음
    • unsafe → 말풍선 특성이 아닌 다른 string도 포함될 수 있음
    • string은 heap에 저장 → 함수 호출할 때 마다 힙 할당 발생

Solution

키를 struct으로 구성하자

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

Swift에서 struct은 최상위(first class) 타입이기 때문에 dictionary에서 key로 사용 가능

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

makeBallon 함수 호출할 때 힙 할당하지 않으므로 (스택 할당) 할당 오버해드 없음.

더 안전하고 빠른 방법

Reference Counting

Swift는 힙의 모든 인스턴스에 대한 총 참조 수 유지, 참조 카운트 증가(retain), 감소(release) 작업 수행하고 참조 카운트가 0일 때 인스턴스 해제

→ 참조 카운팅은 매우 빈번한 작업이며 정수를 늘리고 줄이는 것보다 더 많은 작업

Reference Counting에 고려해야할 것

  1. 간접 참조(Indirection)

  2. Thread safty overhead → 동시에 참조 추가, 해제 막아야 함

    참조 계산 빈도로 인해 이 비용 추가될 수 있음

Class 경우

point1, point2 인스턴스가 할당되고 해제되는 과정



아래 그림과 같이 포인트 인스턴스에 대한 참조가 더이상 없으면 Swift는 힙을 잠그고 해당 메모리 블록을 리턴하는 것이 안전하다고 판단


Struct 경우

일반적으로 struct 인스턴스는 힙에 할당되지 않으므로 reference counting이 일어나지 않으므로 overhead가 존재하지 않는다.

But, complicated struct의 경우는?

// Struct containing references
struct Label {
		var text: String
		var font: UIFont
		func draw() {}
}

let label1 = Label(text: "Hi", font: font)
let label2 = label1
// use `label1`
// use `label2`

UIFont, String 타입인 text가 포함된 Label 구조체

  • String은 heap에 저장되므로 → reference counting 필요
  • Font는 클래스 타입 → reference counting 필요
  1. Label에 2개의 참조가 존재: text, font
  2. 복사본을 만들 때 2개의 참조 추가

→ Label이 실제로 클래스가 갖는 reference counting overhead 2배 발생

참조 카운팅은 자주 발생하고 thread safety를 위한 원자성 때문에 사소하지 않다.

클래스 경우 reference counting overhead 발생

정리

구조체 경우 reference counting overhead 발생하지 않음

참조가 포함된 구조체의 경우, 포함된 참조 수와 비례하여 reference counting overhead 발생

참조가 2개 이상인 구조체인 경우 클래스보다 overhead가 더 많이 발생

적용

첨부파일을 보내는데 필요한 struct

많은 Reference counting overhead 발견: 성능 개선 여지

  • 3개의 참조가 포함된 구조체 → fileURL, uuid, mimeType
// Modeling Techniques: Reference Counting
struct Attachment {
		let fileURL: URL
		let uuid: String
		let mimeType: String

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

Solution

  1. uuid: 무작위로 생성된 128 bit이다.

    이를 String 타입에서 Foundation의 uuid에 대한 새로운 value type인 UUID 사용

    → 문자열이었던 필드에 대한 오버헤드 제거, 또한 UUID만 넣을 수 있어 안전성 up

  2. mimeType를 고정된 케이스를 담는 타입 사용

    Enum 타입으로 변경

    → 참조 카운팅 오버헤드 제거, 안전성 up

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

Method Dispatch

static dispatch

컴파일 시점에 실행할 코드 결정 가능한 호출한다.

컴파일러가 어떤 구현이 실행될 것인지 확인할 수 있어 인라인을 포함한 여러 최적화 작업을 수행할 수 있다.

dynamic dispatch

런타임 시점에 구현을 찾아본 다음 해당 구현으로 이동하여 실행한다.

→ 참조카운팅과 같이 쓰레드와 관련된 오버헤드는 존재하지 않지만 컴파일러의 가시성을 차단하므로 정적 디스패치와 다르게 동적 디스패치의 경우 추론할 수 없어 최적화를 수행할 수 없습니다.

What is Inlining ?

코드 설명

(0,0) Point를 생성하고 해당 점을 drawAPoint에 전달

// Struct (inlining)
struct Point {
		var x, y: Double
		func draw() {
				// Point.draw implementation
		}
}

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

let point = Point(x: 0, y: 0)
drawAPoint(point)

drawAPoint 함수와 point.draw 메서드는 모두 정적으로 전달 → 컴파일러가 어떤 구현을 실행해야하는지 정확히 알고 있음

따라서 이를 아래와 같이 대체가 가능하다.

point.draw() // drawAPoint(point)를 대체한 것 

다시 한번 아래와 같이, drawAPoint 디스패치를 가져와 drawAPoint 구현으로 대체한 것

// Point.draw implementation

이를 통해, 2가지 정적 디스패치의 오버헤드와 호출 스택 관련 설정 및 해제가 필요하지 않음

→ 이런 과정 때문에 정적 디스패치가 동적 보다 훨씬 빠르다.

컴파일러는 호출 스택 오버헤드 없이 단일 구현처럼 정적 메서드 디스패치 체인 축소 가능

Inheritance-Based Polymorphism

동적 디스패치의 강력한 기능 중 하나, 다형성!

코드 설명: 다형적으로 Drawable 배열을 생성하는 프로그램

  • Drawable 배열이고 모두 클래스이므로 배열에 참조로 저장하므로 크기가 모두 동일

각 항목을 검토할 때, 해당 항목에 대해 draw 호출

컴파일러가 실행할 구현이 컴파일 타임에 무엇인지 결정할 수 없다. → Point가 될 수도, Line이 될 수도 있기 때문 (전혀 다른 코드 구현)

Polymorphism Through V-Table Dispatch

그럼 어느 것을 호출할지 결정하는가?

클래스 인스턴스를 저장할 때 정적 메모리에 저장되는 클래스 타입 정보에 대한 포인터를 필드에 추가하여 저장한다. 따라서 draw를 호출할 때, 컴파일러가 실행할 올바른 구현을 가리키는 포인터를 통해 가상 메서드 테이블(V-Table)이라는 항목을 조회한다.

→ v-table을 조회하여 올바른 구현을 찾아 실행한다.

  • 실제 인스턴스를 implicit self-parameter로 전달

정리

class는 dynamic dispatch 방식이다. 그 자체로 큰 차이는 없지만 메서드 체이닝, 인라인과 같은 최적화를 방해할 수 있다.

모든 클래스에 대해서 동적 디스패치가 필요한 것은 아님. 클래스를 하위클래스로 분류할 의도가 없을 경우 final class로 선언한다. 이러면 컴파일러는 해당 메서드를 정적 디스패치한다.

클래스를 서브클래싱하지 않을 것임을 추론하고 증명된 경우 또한 정적 디스패치한다.

필요하지 않은 역동성을 위해 비용을 지불한다면 성능 저하

많은 비용 절감을 위해서는 구조체를 사용해야하는데, 구조체를 사용하여 다형성 코드를 작성하는 방법은 무엇입니까?

답은 Protocol Oriented Programming

다음 편에 계속…..

Related.

0개의 댓글