면접준비

Swift Performance-wwdc24

2료일 2024. 6. 17. 17:17

https://www.youtube.com/watch?v=nb3bRQa0iGQ&t=3s

 

1. Swift에서 말하는 성능이란?

: 성능이란 애플리케이션이 얼마나 빠르고 효율적으로 동작하는지를 나타내는 척도입니다. 여러 측면에서 성능을 평가할 수 있습니다:

  • 지연 시간: 작업이 시작되고 완료될 때까지 걸리는 시간
  • 에너지 소모: 앱이 배터리를 얼마나 효율적으로 사용하는지
  • 메모리 사용: 앱이 얼마나 많은 메모리를 점유하고 어떻게 관리하는지

Swift의 성능은 다차원적이며 상황에 따라 달라질 수 있습니다. 보통 거시적인 관점에서 성능 문제를 분석하지만, 때로는 로우 레벨 성능까지 살펴봐야 할 때가 있습니다.

2. 낮은 수준의 성능을 볼 때 고려해야할 원칙들 

Swift의 로우 레벨 성능을 이해하기 위해서는 Bottom-up 접근 방식으로 생각해야 합니다. 주요 원칙은 다음과 같습니다:

- 최적화되지 않은 함수 호출들: 4가지의 cost가 있다고 한다.  이 중 3가지는 우리(개발자)가 하는것.

  1. 먼저 호출에 의한 인수를 설정(arguments)

 

  • 가장 낮은 수준에서 함수 호출 시 인수를 적절한 위치에 배치해야 합니다.
  • 최신 프로세서에서는 레지스터 이름 변경을 통해 이 비용을 최소화합니다.
  • 실제로는 함수의 소유권 규칙을 충족하기 위해 값 복사가 발생할 수 있습니다.

 

 

  2. 호출하는 함수의 주소 확인 

 

  • 컴파일 타임에 어떤 함수를 호출하는지 알 수 있으면 정적 디스패치(Static Dispatch)를 사용합니다.
  • 알 수 없다면 동적 디스패치(Dynamic Dispatch)를 사용하며, 이는 성능에 영향을 줍니다.
  • 정적 디스패치는 컴파일러가 인라인화, 제네릭 구체화 등 다양한 최적화를 적용할 수 있습니다.

 

정적 디스패치 vs 동적 디스패치

Swift에서는 다음 경우에만 동적 디스패치가 사용됩니다:

  • 불투명한 함수를 호출할 때
  • 오버라이딩 가능한 클래스 메서드를 호출할 때
  • 프로토콜 요구사항을 호출할 때
  • Objective-C나 C++ 함수를 호출할 때

그 외의 모든 경우에는 정적 디스패치를 사용합니다.

 

프로토콜에서 메서드의 위치에 따른 Dispatch

여기서 보면 DataModel 프로토콜 유형 값을 업데이트하는 함수의 호출이 있다. 하지만 이게 static인지 dynamic인지는 아직 모른다. 그 이유는 메소드가 선언된 위치에 따라 달라지기 때문이다. 

  • 프로토콜의 메인 body에서 선언된 경우, 이 함수는 프로토콜 요구사항이된다. 즉 이 프로토콜을 상속할때 무조건 이 함수를 써야한다는 느낌? 그래서 이에 대한 호출은 dynamic Dispatch를 사용한다.
  • 프로토콜의 extension에서 선언된 경우, static Dispatch를 사용한다. 

왜? extension에서 메서드를 추가한경우에는 서브 클래스에서 오버라이딩이 불가능하기에!!!! 하지만 여기서 주의해야할 것이 있다.

protocol IsStaticOrDynamic {

}
extension IsStaticOrDynamic {
  func isStatic() {
     	print("static dispatch")
    }
}

이렇게 프로토콜에 정의되어 있지 않은 것들을 extension에서 추가한 경우 오버라이딩이 불가능하기에 static이지만

protocol IsStaticOrDynamic {
	func isDynamic() 
}
extension IsStaticOrDynamic {
  func isStatic() {
     	print("static dispatch")
    }
  func isDynamic() {
  	print("DYnmaic DIspatch")
    }
}

이렇게 protocol에 선언되어있는것을 extension을 통해 default메서드 구현해놓은 경우에는 어떤게 불려야할지 모르기에 dynamic Dispatch이다. 

  3. 함수의 로컬 상태를 위한 공간 할당

 

  • 함수 실행을 위해 로컬 변수와 임시 상태를 저장할 공간이 필요합니다.
  • 동기 함수는 이 메모리를 C 스택에 할당합니다.
  • 스택 포인터에서 필요한 만큼의 공간을 빼서 CallFrame을 생성합니다.

CallFrame의 이해

함수가 실행되면:

  1. 스택 포인터에서 필요한 바이트 수만큼 공간을 뺍니다(예: 208바이트).
  2. 이 공간을 CallFrame이라 부르며, 함수의 로컬 변수와 상태가 저장됩니다.
  3. 함수 실행이 끝나면 스택 포인터에 다시 같은 크기를 더해 메모리를 해제합니다.


CallFrame은 C 구조체와 유사한 레이아웃을 가지며, 이상적으로는 함수의 모든 로컬 상태가 여기에 포함됩니다. 함수에 필요한 메모리를 CallFrame에 할당하는 것이 가장 효율적입니다.

 

 

  4. 이건 우리가 하지않는것인데 함수 호출자와 호출하는곳에서 최적화를 방해할 수 있다. Optimization restrictions

3번과 4번은 같은 모두 같은 문제로 귀결된다.

바로 컴파일시간에 우리가 호출하는 함수를 정확히 알 수 있는지다. 

Swift는 강력한 optimizer가 있다. 그래서 컴파일러가 많은 costs들을 제거해주기에 이외의 요소들도 있지만 고려안해도된다.

 

 메모리 할당 종류

메모리는 총 3가지가 있다. 

  • Global
    • 프로그램 로드 시 할당되고 초기화됩니다.
    • 거의 할당 비용이 없습니다.
    • 단점: 고정된 크기이며, 프로그램 실행 내내 메모리가 유지됩니다.
    • 사용 예: 글로벌 변수, 정적 멤버 변수
  • Stack
    • CallFrame 예시에서 본 것처럼 할당 비용이 적습니다.
    • 메모리의 범위가 제한적이어야 합니다(함수 내부 등).
    • 사용 예: 일반적인 로컬 변수
  • Heap(힙에관한글은 진짜 바로직전글)
    • 매우 유연하며 언제든지 할당하고 해제할 수 있습니다.
    • 할당 및 해제 비용이 다른 종류보다 훨씬 높습니다.
    • 사용 예: 클래스 인스턴스, 크기가 동적인 데이터(문자열, 컬렉션 등)
    • Swift는 레퍼런스 카운팅으로 힙 메모리의 수명을 관리합니다.

이 모든것은 Ram에 있지만 다른 패턴으로 각각을 할당하고 사용한다. 

-  데이터가 표현되는 방법으로 인한 메모리 낭비(Memory layout)

메모리에 어떻게 저장되어있니? 어...값타입으로! 

어 맞지 근데 여기서는 "representation"으로 설명한다. 이는 값이 메모리에 어떻게 보이는지를 말한다.

array변수는  버퍼개체에 대한 참조를 유지하는 메모리의 이름이다. 

"inline representation"은 포인터 따라가지 않는 representation이라고 하자. 저 위에서 인라인 representation은 단일 버퍼참조이며 버퍼가 실제로 포함된 내용들을 무시한다. 측정을 해보면 64비트, 즉 8바이트다. 

친숙하다 CallFrame. 로컬 범위에 포함된 배열값을 가지게 되는데, 이는 CallFrame에 인라인 표현에 배치한다.  실제 해당 포인터를 저장한다. 

 

2.4 값 복사와 소유권

Swift의 소유권 시스템은 메모리 안전성의 핵심입니다. 값을 사용할 때 세 가지 방식으로 소유권과 상호작용합니다:

  1. 소모(Consume):
    • 표현의 소유권을 한 곳에서 다른 곳으로 이전합니다.
    • 예: 변수 초기화, 함수에 값 전달
    • consume 연산자를 사용해 명시적으로 소모를 표현할 수 있습니다.
  2. 변경(Mutate):
    • 변경 가능한 변수에 저장된 값의 소유권을 일시적으로 가져옵니다.
    • 메서드가 완료되면 새 값의 소유권을 다시 변수에 넘깁니다.
    • 예: 배열에 요소 추가, 구조체 프로퍼티 수정
  3. 대여(Borrow):
    • 값을 읽기만 하고 변경하지 않을 때 사용합니다.
    • 예: 값을 출력하거나 함수에 읽기 전용으로 전달할 때
    • 때로는 Swift가 방어적으로 값을 복사해야 할 수도 있습니다.

값을 복사하는 비용은 타입의 인라인 표현에 따라 달라집니다:

  • 클래스 값 복사: 객체에 대한 참조만 복사하므로 비용이 적습니다.
  • 구조체 값 복사: 모든 저장 속성을 재귀적으로 복사하므로 큰 구조체는 비용이 많이 들 수 있습니다.

array2에 넣어주면 값은 배열이므로 복사한다는 것은 해당 버퍼를 유지한다는 뜻. 만약 컴파일러가  여기서 array변수가 마지막으로 쓰이고 안쓰인다는 것을 알아? 복사본없이 여기에 값을 전송할 수 있어야 함. 

var array2 = consume array라고 쓰면됨. 

mutating: 이후에도 값의 소유권을 갖는다. 

Borrowing: print와 같이 컴파일러가 저거에 대해 건드리지 않을것을 알고 빌려준다. 

좀 더 높은 레벨에선,  함수의 컨벤션에 맞게 컴파일러가 값의 복사본을 추가해야할 수 있다.'

inline stoarge:

- heap할당을 피한다

- 작은 타입에 해야함. 전부 복사하기에 성능저하가 일어날 수 있다. 

 

3. Swift의 고급 기능과 성능 영향

Swift 타입의 크기는 두 가지 경우에 런타임에 결정됩니다:

  1. SDK의 많은 타입(예: Foundation의 URL)은 향후 OS 업데이트에서 변경될 수 있습니다.
  2. 제네릭 타입의 매개변수는 다양한 크기의 타입으로 대체될 수 있습니다.

Swift는 이러한 동적 크기 타입을 처리하기 위해:

  • 컴파일러가 타입의 정적 부분 레이아웃만 파악하고 나머지는 런타임에 결정합니다.
  • 필요한 경우 별도의 메모리 할당을 사용합니다(글로벌 변수, 로컬 변수 등).

이럴때 첫번째 속성에 갈때까지는 레이아웃을 알고 있으나 이후에부터는 런타임에 의해 동적으로 채워진다. Cstack에서도 마찬가지로 96만큼만 가지고 있다가 런타임에 정해지면 다시 변수크기만큼빼서 stackPointer를 내려준다. 

 

Async함수에서는 어떻게 작동할까?

비동기 함수는 일반 동기 함수와 다르게 작동합니다:

  1. 로컬 상태를 C 스택이 아닌 별도의 비동기 스택에 저장합니다.
  2. 실행 시 여러 "부분 함수"로 나뉘어 실행됩니다(await 지점마다 분리).

비동기 함수는 메모리 슬랩(memory slab)을 사용해 효율적으로 메모리를 관리합니다:

  • 작업이 슬랩에서 필요한 메모리를 할당합니다.
  • 슬랩이 가득 차면 새로운 슬랩을 할당합니다.
  • 이 할당자는 일반 malloc보다 빠르지만, 함수 호출에 약간의 오버헤드가 추가됩니다.

여기서 로컬함수들은 정지지점을 cross하는데 쓰이므로 C스택에 저장할 수 없다. 

 

마지막 제너릭

여기 두 updateAll의 차이가 뭘까? 

첫번째꺼는 동일한 모델이 array에 들어간다. 타입에 대한 정보는 함수에게 한번만 전달된다. 

두번ㅉ꺼는 뭐 다른 DataModel에 맞는 프로토콜이 전부들어올수있다. 당연히 최적화도 훨 어렵다. 컴파일러가 일일이 다 측정해야하기에..

사실 뒤로갈수록 너무 어려웠다...그래서 댓글에 각자 생각을 알려주시면 참고하도록 하겟슴다..