apple/Docs, iOS, Swift

[WWDC24] Swift의 성능 살펴보기

lgvv 2024. 7. 28. 22:02

[WWDC24] Swift의 성능 살펴보기

Swift의 Low-level에 대해서 알아볼 예정

 

Introduce

- What is performance?

: 성능이란?

- Low-level principles

: Low-level의 성능을 볼 때 고려해야 할 원칙

- Putting it together

: Swift의 어떻게 구현되는지, 성능에 어떤 영향에 미치는지 세부 사항을 알아보기

 

- What is performance?

성능을 다차원적이고, 상황에 따라 달라집니다. 일반적으로 우리는 거시적인 문제 때문에 성능에 관심을 갖는다.

 

Macroscopic goal: 거시적인 목표

 

UI가 클릭하기 힘들 정도로 버벅이는 등의 문제가 생김.

 

하향식 성능 평가

하향식 방법을 조사

많은 경우 코드의 Low-level 수준의 성능에 영향을 주지 않고 알고리즘 개선을 통해 이러한 문제를 해결.

 

상향식 성능 평가

하지만 때로는 Low-Level 적으로 문제를 접근해야할 수도 있음.

알고리즘 수준에서 더 이상 수행할 수 있는 작업이 없을 수도 있음.

 

 

Low-Level 성능 평가에는 4가지 고려사항에 의해 좌우된다.

1. 효과적으로 최적화되지 않은 많은 함수 호출 수행

2. 데이터가 표현되는 방식 때문에 많은 시간이나 메모리를 낭비

3. 메모리 할당에 너무 많은 시간 소비

4. 값을 복사하고 파괴하는 데 많은 시간 소비

 

Swift의 대부분 기능은 이러한 비용 중 하나 이상에 영향을 미침.

 

마지막으로 고려해야하는 영향을 미치는 Swift 강력한 최적화 프로그램.

코드 작성 방식은 최적화 프로그램이 수행할 수 있는 작업에 상당한 영향을 미침.

 

 

옵티마이저에 의존하기 불편하다면 다음의 방식을 제안

 

성능이 프로젝트의 중요한 부분인 경우 정기적으로 모니터링 해야함.

이렇게 할 경우에는 성능 측정 도구를 이용해 핫스팟(특정 지점)을 찾는다면, 그 부분을 개발 프로세스의 일부로 해당 측정을 자동화 할 것

 

Function calls

함수 호출

 

1. Put the arguments in the right places: 함수 호출에 대해서 인수 설정

2. Resolve the function: 호출하는 함수의 주소 확인

3. Allocate soace for the function's local state: 함수의 로컬 상태를 위한 공간 할당

4. Optimization restrictions: (우리가 직접하는게 아니라 시스템이 함)

 

이 네가지가 호출자와 호출하는 함수 모두에서 최적화를 방해할 수 있음.

 

인수 호출

 

- Put the arguments in the right places

인수 전달을 먼저 보면 두가지 비용이 존재. low-level에서 호출 규칙에 따라 올바른 위치에 인수를 넣어야 하며, 최신 프로세서에서는 이러한 비용이 일반적으로 레지스터 이름 변경으로 숨겨짐. 그래서 실제로는 큰 차이 없음

값 복사

그러나 high-level에서는 컴파일러가 함수의 소유권 규칙과 일치하도록 값의 복사본을 추가해야 할 수도 있음.

 

 

 

호출하는 함수 주소 확인 및 최적화

 

이 두가지도 위와 동일한 문제로 귀결.

 

컴파일 타임에 우리가 호출하는 함수를 정확히 알 수 있을까>

-> 알수 있다면 정적 디스패치

-> 알수 없다면 동적 디스패치

 

call dispatch

 

정적 디스패치는  더 효율적이고 프로세서 수준에서 조금 더 빠르지만 더 중요한 것은

컴파일러가 해당 함수 정의를 볼 수 있는 경우 인라인 및 일반 특수화와 같이 컴파일 타임에 가능한 많은 최적화가 있음.

 

그러나 동적 디스패치는 다형성과 추상화를 위한 강력한 도구를 제공하여 가능케함.

 

Swift는 대부분을 특수한 경우들을 동적으로 처리함

따라서 Swift에서는 특정 종류의 호출만 동적으로 처리함.

 

 

프로토콜이 선언된 위치에 따라 다름

 

func updateAll()을 호출하는 부분을 보자. 어떤 종류의 호출인지는 메소드가 선언된 위치에 따라 다름

본문에 선언된 경우 프로토콜의 요구사항으로 이는 동적 디스패치 사용

프로토콜의 extension에 선언된 경우에는 정적 디스패치 사용

 

 

 

로컬 메모리 할당

 

해당 함수를 호출하려면 약간의 메모리 할당 필요

위 함수는 일반적인 동기함수 이므로 해당 메모리를 C 스택에 할당

C 스탹에 공간을 할당하려면 스택 포인터에서 넣고 빼기만 하면 된다.

 

어셈블리어

 

위의 코드를 컴파일하면 스택 가장 위부터 시작하는 시작하는 아래의 어셈블리어를 얻게 된다.

처음 시작할 때 call frame을 208 바이트만큼 할당하고 실행 가능한 공간을 제공, 이후 실행이 끝날때 208 바이트 만큼 추가해서 이전에 할당 메모리를 해제함.

 

call frame

 

C의 구조체의 레이아웃과 같다고 생각할 수 있지만 모든 로컬 필드가 call frame이 된다.

 

 

Memory allocation

 

전통적인 설명

Global, Stack, Heap 영역이 존재하나 궁극적으로는 다 하나의 RAM에서 나옴.

하지만 우리는 프로그램에서 각각을 다른 패턴으로 할당하고 사용하고 이는 운영체제 및 성능에 중요함

 

 

Global 할당

 

 

이는 거의 비용이 없는건 아니지만 free에 가까움.

하지만 가장 큰 단점은 프로그램 전체 기간 동안 유지되는 고정된 양의 메모리가 존재해 특정 패턴에 대해서만 활용(작동) 가능

Stack 할당

 

이도 Global처럼 거의 비용이 없음.

하지만 특정 패턴에서만 동작함.

이것은 범위가 지정되어야 함. 

 

Heap 메모리

힙은 매우 유연해 임의 시간에 할당하고 해제할 수 있음.

이로인하여 힙 할당 및 해제 비용이 다른 것에 비해 비쌈.

힙은 클래스 인스턴스와 같은 명백한 항목에 사용.

다른 항목을 사용할 수 있을 만큼 강력한 정적 수명 제한이 없는 일부 기능에도 사용

 

종종 힙 메모리 할당할 때 메모리는 공동 소유권 가짐.

동일한 메모리에 대해 여러개의 독립 참조를 갖음.

이는 레퍼런스 카운팅으로 관리.

- retain: 참조 카운트 늘리기 

- release: 참조 카운트 줄이기

 

Memory Layout

Swift가 해당 메모리를 사용하여 값을 저장하는 방법을 알아보자.

 

 

High Level

일반적으로 메모리 어디에 저장되어 있는지 관계 없이 개념에 대해서 이야기함.

두개의 Double 배열임.

 

 

 

Low-Level

 

Low-Level에서는 여전히 값이라고 부르지만, Low-Level에서는 메모리에서 기록되는 방식임.

변수 배열은 버퍼객체를 참조하는 형태로 기록됨.

 

인라인 표현

 

인라인 표현: 어떤 포인터도 따르지 않고 얻을 수 있는 표현의 일부만을 의미

여기에서는 배열의 인라인 표현은 단일 버퍼 참조. (버퍼에 대한 실제 값은 무시)

즉, 표준 라이브러리의 MemoryLayout 유형은 인라인 측정으로만 사용함.

 

즉, 배열의 경우 단일 64비트 포인터 크기인 8바이트에 불과

 

Value Context

 

Swift의 값은 Context의 일부.

 

값에 타입 존재

 

값에도 타입이 존재함.

 

 

인라인 설명

함수 내에 로컬 배열은 CallFrame을 아래처럼 갖는다.

 

아래에서 결국은 표준 라이브러리 소스를 보면 결국 배열에는 단일 데이터 타입으로 저장되어 있으므로, 이는 클래스 타입.

클래스는 결국 객체에 대한 포인터일 뿐임.

따라서 실제로 callFrame은 해당 포인터를 저장하여 최적화함.

 

Swift에서 구조체 튜플 열거형 모두 인라인 저장소를 사용.

포함된 모든 내용은 일반적으로 선언된 순서대로 컨테이너에 인라인으로 배치.

 

클래스와 액터는 외부 저장소를 사용.

포함된 모든 내용은 일반적으로 객체에 인라인으로 배치되고, 컨테이너는 포인터만 저장함.

-> 성능에 큰 영향을 미침

 

Value Copying

Swift에서는 소유권이란 개념이 있어서 값을 관리하는 개념에 대해서 알아봄.

값 오너십

 

 

배열의 인라인 표현이 결국은 버퍼 객체에 대한 참조임. (같은 속성 -> 클래스 -> 클래스는 객체 타입)

참조 카운팅을 사용하여 관리된다.

컨테이너가 배열 값의 소유권을 가지고 있다고 말할 때, 이는 배열 버퍼가 변경될 가능성이 있다는 말임.

(포인터를 갖고 있고, 포인터가 가르키는 값은 무시하므로 가르키는 값이 바뀔 경우에 바뀐다는 말)

 

릴리즈할 때는 특별한 일이 없다면 컨테이너가 로컬 범위에서 사라질 때 해제된다.

 

오너십

 

Swift에서 값이나 변수를 사용할 경우 소유권 시스템과 상호작용함

- 3가지가 있는데, 값을 소유하거나, 변경하거나, 빌리거나

 

값을 소유한다

값을 소유한다는 것은 그 표현의 소유권을 한 곳에서 다른 곳으로 이전한다는 것을 의미.

자연스럽게 값을 소비하는 가장 중요한 작업은 메모리에 할당하는 것.

 

두번째 변수로 할당

 

array 첫번째는 값을 새로 생성하고 메모리에 할당하고 독립적으로 소유함.

array2 = array 과정에서는 값의 소유권을 다시 새 변수로 이전해야 함.

array2는 이때 값을 새로 생성하지 않고 기존 array을 참조함.

독립 적인 값을 얻으려면 현재 값을 복사해야 하며, 값이 배열이므로 복사한다는 것은 해당 버퍼를 유지한다는 말임.

 

하지만 이는 최적화되는건 아닌데, 컴파일러가 원래 변수를 더 이상 사용하지 않는다는걸 알게되면 복사본 없이 여기에 값을 전송할 수 있어야 함.

명시적으로 consume 연산자를 사용해 값을 할당 가능

 명시적으로 소비되는 이 지점을 지나서 값을 사용하려고 할 경우 더 이상 값이 없다고 알려줄 것임.

 

값 변화

 

값을 변경한다는 것은 소유권을 일시적으로 가져옴

소비와의 차이는 변수가 이후에도 소유권을 가질것으로 예상한다는 것임.

값을 할당할 경우

값을 할당할 경우 해당 변수에 있는 소유권이 아래 메서드로 이전함.

Swift는 내부적으로 호출중에 변수를 다른 방식으로 동시에 사용하는 것을 방지

메서드가 완료되면 새 값의 소유권을 다시 변수로 이전

> 이는 변수가 소유권을 유지한다는 불변성을 유지

 

값을 빌리기

단순히 값을 읽고 싶은 경우 자연스럽게 수행되는 작업임.

 

빌리는 간단한 예시

가장 흔하게 빌리는 예시

하지만, Swift에서는 값을 빌려주는 대신 방어적으로 복사해야 하는 상황이 있음.

값을 빌리려면 Swift는 변경하거나 소비하려는 시도가 없어야 함을 입증해야 함.

 

빌리는 좀더 복잡한 예시

 

저장소가 클래스 속성에 있는 경우 속성이 동시에 수정되지 않았다는걸 증명해야 함.

이는 Swift에서 내부적으로 명시적으로 값을 빌릴 수 있는 새로운 기능을 통해 개선사항을 적극적으로 발전시키는 중.

 

값을 복사한다는 것이란?

 

결국 인라인 표현에 다르다는 말.

값을 복사한다는 것은 독립적인 소유권을 가진 새로운 인라인 표현을 얻기 위해 인라인 표현을 복사하는 것을 의미

 

성능 트레이드 오프

 

인라인 스토리지는 힙에 할당하는 걸 피함.

> 일반적으로 작은것에 좋음.

그러나 크거나 반복적으로 할당을 수행해야 하는 경우에는 Heap에 할당하는것이 성능이 더 좋음.

 

결국 개발자의 몫이며 엄격하고 빠른 것에 정답은 존재하지 않음.

큰 구조체의 복사

 

큰 구조체의 복사는 두 부분으로 나뉨.

1. 값 유형을 복사할때 비트만 복사하지 않는 경우

클래스로 만든 객체 복사할 때는 클래스 객체가 유지되어야 한다.

이를 구조체로 복사하면 실제로는 세가지 필드가 복사된다.

 

즉, 모든 구조체 내부에 속한 모든 저장된 속성에 대한 자체 저장소가 필요함.

> 이를 매우 많이 복사해야할 경우 결국 더 많은 메모리가 필요할 수 있음.

 

라인 외부 저장소 사용하는 경우에는 더 나을수도 있음. (메모리 재사용하기 때문에)

-> 클래스는 out-line 저장소 사용해서 그게 더 낫다는 말임.

 

구조체는 위에서 보인 방식으로 작동하지만 항상 인라인 저장소 사용

하지만 클래스는 아웃라인 저장소를 사용하지만 참조 체계를 가짐.

 

라인 외부 저장과 값 semantics를 모두 가져가려면

클래스는 struct로 래핑한 다음 복사하는 것이다.

 

표준라이브러리는 내부적으로 배열, 딕셔너리, 스트링 등 모든 기본 데이터 구조에서 이 기술을 사용함.

 

 

Putting it together

 

구조체, 클래스, 함수와 같은 몇가지 기본 Swfit 기능으로 변환하는 방법을 알아보자.

 

동적으로 크기가 조정되는 유형

 

C의 구조체는 항상 일정한 크기이지만 Swift의 구조체는 두가지 경우에 런타임에 결정 가능

제네릭이나 URL처럼 컴파일 타임에 알 수 없느 경우 동적으로 조정함.

하지만 더 적은 바이트로 처음에 저장할 수 있음.

비동기 함수

 

핵심 아이디어는 스택에 미리 하나를 할당해두고, 이를 여러개로 쪼개서 사용하는 방식으로 구현되어 있음.

즉 최대 하나의 C 스택 영역만 존재함.

 

비동기의 경우

 

비동기 하나를 수행할 때 해당 callFrame을 늘리고, 사용이 끝나면 줄임. 두번째에서는 callFrame을 늘리고 이런식으로 반복

중단되는 경우에도 비워냄.

 

 

 

클로저

클로저의 경우에는 nonescpaing의 경우 외부에서 사용되는게 아닌걸 알아서 스택

escaping의 경우에는 탈출해야해서 힙

 

 

제네릭

제네릭의 경우 정적으로 알려지지 않은 타입을 사용할 수 있음.

Swift의 프로토콜은 런타임 시 프로토콜의 각 요구사항에 대해 하나씩 함수 포인터 테이블로 표시

해당 테이블은 C에서는 

 

 

any 여부에 따른 차이점

호출자가 어떠한 타입인지 알면 최적화 가능

이렇게 할 경우 제네릭하는 비용이 줄어듦.

 

any로 할 경우 이는 최적화하기가 어려움.

성능이 완전히 저하되지는 않지만, any의 지점에서는 컴파일러의 도움을 덜 받음.

 

> 성능적인 측면에서 프로토콜 유형을 사용하지 말라고 함.

> 하지만 이는 비용에 관련한 이야기 일 뿐 추상화는 여전히 필요하며, 이를 활용해야 함.