WWDC - UnderStanding Swift Performance (1/3)

2024. 5. 28. 12:01숨참고 딥다이브/WWDC

728x90

 

 

Understanding Swift Performance - WWDC16 - Videos - Apple Developer

In this advanced session, find out how structs, classes, protocols, and generics are implemented in Swift. Learn about their relative...

developer.apple.com

 

Understanding Swift Performance

In this advanced session, find out how structs, classes, protocols and generics are implemented in Swift. Learn about their relative costs in different dimensions of performance. See how to apply this information to speed up your code.
- 구조체, 클래스, 프로토콜 및 제네릭이 Swift에서 어떻게 구현되는지
- 성능 측면에서 상대적인 비용이 얼마나 다른지
- 이를 적용해서 코드의 속도를 높이는 방법에 대해서 알아보자.


Swift의 다양한 추상화 메커니즘이 모델링에 미치는 영향

  • `Value` or `Reference` 중에 어느 것이 더 적절할까?
  • `추상화`가 얼마나 `동적`이어야 할까?

→ 성능에 미치는 영향을 고려하면 보다 관용적인 해결책을 찾는 데 도움이 되는 경우가 많다.

 

 

Understand the implementation to understand performance

`해당 메커니즘의 기본 구현을 이해`
Swift의 추상화 메커니즘이 성능에 미치는 영향을 이해하는 가장 좋은 방법은 해당 메커니즘의 기본 구현을 이해하는 것이다.

  • 추상화를 구축하고 추상화 메커니즘을 선택할 때에는
    • `내 인스턴스가 스택에 할당될 것인가, 아니면 에 할당될 것인가?`
    • `이 인스턴스를 전달할 때 얼마나 많은 reference counting 오버헤드가 발생할까?`
    • `이 인스턴스에서 메서드를 호출할 떄 정적으로 실행될 것인가 아니면 동적으로 실행될 것인가?`
    → Swift 코드를 빠르게 작성하려면 `동적 기능과 런타임에 대한 비용`{purple}을 적게 해야한다.

 

Allocation

Allocation - Stack ⇒ 결론! 스택 할당은 엄청 빠르다!!

  • 메모리 중 일부는 `스택`에 할당된다.
    • `스택` : FILO(선입 후출), 높은 주소에서 낮은 주소로 메모리를 할당한다.
    • 함수를 호출할 때 스택 끝에 있는 포인터를 스택 포인터라고 부르는데,
      • stack의 에서만 데이터의 출입이 일어남
        stack의 끝에 포인터만 유지하면 된다.

 

 

 

 

 

 

  • Decrement stack pointer to allocate
    • 함수를 호출할 때 `스택 포인터를 약간 줄여서 공간을 확보하는 것만으로도 필요한 메모리를 할당`{purple}할 수 있다.
    • 먼말이냐면,,,,  👉 스택 포인터는 스택의 가장 위쪽 데이터의 위치를 가리키고 있고(= 높은 주소를 가리키고 있음), 스택 포인터를 약간 낮은 주소를 가리키게 함으로써 (= 스택은 높은 주소에서 낮은 주소로 메모리를 할당하기 때문에) 메모리를 할당할 수 있다.
  • Increment stack pointer to deallocate
    • 함수 실행이 끝나면 스택 포인터를 이 함수를 호출하기 전의 위치로 `되돌리기만 하면`{purple} 해당 메모리를 간단하게 해제할 수 있다.
    • 먼말이냐면,,, 👉 메모리가 할당된 지금은 스택 포인터가 낮은 주소를 가리키고 있는데, 이를 높은 주소를 가리키게 하면 간단하게 메모리를 해제할 수 있다.
  • 결론) 스택 할당은 `O(1) 시간`으로 엄청 빠르다!

⇒ `Value Semantic`

  1. 코드 실행을 시작하기 전에 `스택에(= 객체나 데이터의 수명이 컴파일 시점에 고정됨)`{purple} point1 인스턴스와 point2 인스턴스를 위한 공간을 할당함
    • point들은 `구조체`이기 때문에 구조체 내부의 x와 y 프로퍼티는 `스택에 일렬로 저장`{purple}됨
  2. (x: 0, y:0) point를 만들려면 스택에 이미 할당된 메모리를 초기화하는 작업만 수행하면 된다.
  3. point1을 point2에 할당하면 point1의 복사본을 만들고 스택에 이미 할당된 point2 메모리를 초기화
  4. point1과 point2는 `독립적인 인스턴스`{purple}이다. → point2.x = 5이지만 point1.x == 0이기 때문
  5. 함수가 종료된 후에 스택 포인터를 다시 원래의 위치로 증가시키면 point1과 point2에 대한 메모리를 간단하게 할당 해제할 수 있다.

Allocation - Heap

  • `힙` 은 스택보다는 `동적`{purple}이지만 `효율성이 떨어진다.`{purple}
  • Advanced data structure
    • 힙은 `dynamic lifetime`을 가진 메모리를 할당할 수 있다. (스택은 못 함)
      • dynamic lifetime: 프로그래밍 객체나 데이터의 수명이 `런타임` 중에 결정되는 것, 컴파일 시점에 고정되지 않음
  • Search for unsed block of memeory to allocate
    • 힙에 메모리를 할당하려면, 힙 데이터 구조를 검색하여 `적절한 크기의 사용되지 않는 블록`을 찾아야 한다.
  • Reinsert block of memory to deallocate
    • 작업을 완료한 후 메모리를 할당해제하려면, 해당 메모리를 적절한 위치에 `다시 삽입`해야한다.
    • 먼말이냐면,,, 👉  메모리 사용이 끝난 후 프로그래머가 메모리를 해제하면, 이전에 할당받았던 메모리 블록이 다시 힙에 반환되어야 해서 반환될 메모리 블록을 힙의 적절한 위치에 다시 삽입한다는 뜻이다. 해제하면 원래 위치에 그냥 있는거 아닌가? 싶지만 메모리 해제 후 반환되는 메모리 블록이 힙 내에서 효율적으로 재배치되어야 하기 때문에 적절한 위치를 찾아보는 것 같음, 가용 메모리 공간이 쪼개져서 여러 군데에 흩어져 있으면 비효율적이니까

→ 이러한 과정이 있기 때문에 스택보다 오래 걸린다. 그런데 사실은 이것 보다 더 큰 비용이 있다!!

  • Thread safety overhead
    • `여러 스레드가 동시에 힙에 메모리를 할당할 수 있기`{purple} 때문에 힙은 locking 또는 기타 동기화 메커니즘을 사용하여 `무결성(integrity)을 보호`해야한다. → pretty large cost

⇒ `Reference Sematic`
     참조하다: 특정 메모리 영역을 가리킨다. 실제 데이터가 저장된 메모리의 위치에 있는 데이터에 접근

  1. 함수에 들어가면 value semantic과 마찬가지로 `스택`{purple}에 메모리를 할당한다.
    • 하지만 stack에 point 프로퍼티를 실제로 저장하는 것이 아니라,`point에 대한 참조를 위해 메모리에 할당한 것`이다.
      즉, `heap에 할당할 메모리에 대한 참조`{purple}
  2. (x: 0, y:0)을 만들면 Swift는 `힙을 잠그고` 데이터 구조에서 `적절한 크기의 사용되지 않는 메모리 블록을 검색`한다.
    ← `무결성`을 위해서
  3. 메모리를 확보하면 해당 메모리를 x = 0, y = 0으로 초기화하고 `힙에 있는 해당 메모리에 대한 메모리 주소로 point1의 참조를 초기화할` 수 있다.
    • stack의 point1이 heap의 메모리 주소를 참조하고 있다.
    • 구조체에서는 Swift는 2개의 word에 대한 스토리지를 할당하는데
    • 클래스 point에 4개의 word에 대한 스토리지를 할당하고 있다.
    → Swift가 우리를 대신해서 관리할 두 개의 word를 더 할당하고 있다. (힙 다이어그램에서 2개의 파란색 박스)
  4. point1을 point2에 할당할 때 point1이 point2의 내용을 복사하지 않고, `참조를 복사`하고 있다.
    • point1과 point2는 실제로 힙에 있는 동일한 point 인스턴스를 참조하고 있다.
    • `의도하지 않은 상태 공유`{purple}로 이어질 수 있다.
  5. 함수를 다 실행하고 난 후에 우리를 대신해서 Swift가 힙을 잠그고 사용하지 않는 블록을 적절한 위치로 재삽입하면서 메모리를 할당해제하고 나서야 스택을 pop할 수 있다.

Allocation 비교

  • Class
    • `클래스는 힙 할당`이 필요하기 때문에 구조체보다 구성 비용이 더 많이 든다.
    • 클래스는 힙에 할당되고 reference semantic을 가지므로, 클래스는 identityindirect storage와 같은 몇가지 강력한 특성을 가지고 있다.
      • identity: 메모리에서의 위치(주소)가 고유한 식별자
      • indirect storage: 참조를 통해서 클래스 인스턴스에 접근(= 힙에 저장되기 때문에)하기 때문에 인스턴스의 크기가 변하더라도 `참조 크기는 변하지 않는다.`{purple}
    → 추상화를 위해 이러한 특성이 필요하지 않다면 구조체를 사용하는 것이 좋다.
  • Struct
    • 구조체는 클래스처럼 `의도하지 않는 상태 공유가 발생하지 않는다.`

Allocation - Swift 코드의 성능 개선

  • `String`은 Dictionary의 `key`에 특별히 강력한 유형은 아니다.
    • 제한이 없기 때문에 아무 내용이나 넣는 휴먼 에러를 발생할 수 있고
    • String은 실제로 문자의 내용을 `힙에 간접적으로 저장`{purple}하기 때문에 makeBalloon함수를 호출할 때마다 cache에 key에 대한 value가 있더라도(= 캐시 히트가 있더라도) `힙 할당이 발생`한다.

  • `Struct`를 사용해서 key를 표현할 수 있다.
    • 제한된 타입만 사용할 수 있기 때문에 String보다 훨씬 안전한 방법이고
    • Struct는 일급 유형이기 때문에 Dictionary에서 `key`로 사용할 수 있다.
  • Struct는 힙 할당이 필요하지 않기 때문에 Cache Dictionary에서 캐시 히트가 발생해도 `힙 할당에 대한 오버헤드가 발생하지 않는다.` 스택에 할당하기 때문에!

⇒ 훨씬 더 안전하고 더 빨라질 것이다.

 

Reference Counting

  • Swift는 힙에 할당된 메모리를 `언제 해제해도 안전`한지 어떻게 알 수 있을까?
    • Swift는 `힙`에 있는 모든 인스턴스에 대한 총 `reference count(참조 횟수)를 계산`하고,
    • 그리고 `인스턴스 자체(instance itself)에 보관`한다.
    • reference count이 `0`이되면 더 이상 힙에서 이 인스턴스를 가리키는 객체가 없으므로 해당 메모리를 할댕 해제해도 안전하다.

Thread safety overhead (class vs struct)

  • `여러 스레드에서 동시에` 힙 인스턴스에 reference를 추가하거나 제거할 수 있기 때문에 reference count를 `원자(automically) 단위`로 늘리거나 줄여야 하므로 → `thread safety`을 고려해야한다.
class Point {
	var reference: Int
	var x, y: Double
	var draw() { ... }
}

let point1 = Point(x: 0, y: 0)  // point 인스턴스에 대한 참조 카운트: 1
let point2 = point1

// retain은 reference count를 원자 단위로 증가시킴
retain(point2)  // point1을 point2에 할당 -> point1에 대한 참조 카운트: 2
point2.x = 5

// use `point1`
// release는 reference count를 원자 단위로 감소시킴
release(point1) // point1의 사용이 끝나면 더 이상 살아있는 참조X ~ 참조수를 원자적으로 감소
// use `point2`
release(point2)
  •  

 

  • `retain`과 `release`를 통해서 Swift는 힙에 얼마나 많은 reference가 살아있는지를 추적할 수 있다.
  • 인스턴스를 사용하는 reference가 없는 시점에는 Swift는 힙을 잠그고, 해당 메모리 블록을 반환해도 안전하다는 것을 알고 있다.

  • 구조체에는 reference counting이 포함될까?
    • 구조체를 만들 때에는 힙 할당이 포함되지 않는다. → struct에 대한 reference counting 오버헤드가 없다.

Struct containing references

struct Label {
	var text: String  // String은 힙에 문자의 내용을 저장한다 -> **reference counted** 해야함
	var font: UIFont  // UIFont는 class 타입이다. -> **reference counted** 해야함
	func draw() { ... }
}

let label1 = Label(text: "HI", font: font)
let label2 = label1  // 복사본을 만들 때 

retain(label2.text._storage)
retain(label2.font)  // reference를 두 개 추가한다.
// use `label1`
release(label1.text._storage)
release(label1.font)
// use `label2`
release(label2.text._storage)
release(label2.font)

  • 복사본을 만들 때 실제로 text_storage와 font에 대한 reference를 `두 개` 더 추가한다.
    • 이러한 힙 할당을 추적하는 방식: retain 과 release 호출을 추가함
    → Label 구조체는 `실제로 클래스보다 두 배의 reference couting 오버헤드를 발생`{purple}시킨다.

Summary

  • `클래스는 힙에 할당`되기 때문에 Swift는 `reference counting`을 통해 힙 할당의 수명을 관리해야한다.

  • `구조체에 reference가 포함`된 경우, `reference counting 오버헤드`에 대한 비용이 추가된다.
    • 구조체는 포함된 `reference의 수에 비례`해서 reference counting 오버헤드 비용이 추가됨
    • 따라서 reference가 `두 개 이상`이면 `클래스보다 더 많은 reference counting 오버헤드를 유지`{purple}한다.

struct Attachment {
	let fileURL: URL
	let uuid: String
	let mimeType: String
	
	init?(fileURL: URL, uuid: String, mimieType: String) {
		...
	}
}

extension String {
	var isMimeType: Bool {
		switch self {
		case "image/jpeg":
			return true
		...
		}
	}
}
}

 

struct Attachment {
	let fileURL: URL
	let uuid: UUID          // reference type 아님
	let mimeType: MimeType  // reference type 아님
	
	init?(fileURL: URL, uuid: String, mimieType: String) {
		...
	}
}

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

enum MimeType: String {
	case jpeg = "image/jpeg"
	...
}
  • UUID는 128 비트 무작위로 생성된 식별자이다.
    → String 대신 `UUID`를 사용하면 `reference counting 오버헤드를 제거`할 수 있다.
  • `enumeration`: 고정된 집합을 표현하는 추상화 메커니즘
    → String 대신 `enum`을 사용하면 `type safety`가 향상되고, 다양한 케이스를 `힙에 간접적으로 저장할 필요가 없기 때문에` 성능도 향상된다. 

⇒ 훨씬 더 강력하고, 성능 특성이 동일하지만 작성하기가 훨씬 더 편리하다는 점을 제외하면 사실상 완전히 동일한 코드이다.

⇒ 강력하게 타입이 지정된 uuid와 mimeType 필드는 `reference counting이나 heap allocation이 필요하지 않기 때문에 reference counting overhead를 거의 지불하지 않는다.`{purple}

 

 

728x90