iyOmSd/Title: Swift

[Swift] WWDC24 Explore Swift Performance(1)

냄수 2024. 11. 9. 19:39
반응형

WWDC24 Explore Swift Performance를 보고 정리한 글입니다! 🙇‍♂️

 

C언어에서는 깔끔한 기계어 코드를 얻을 수 있지만 Swift는 그렇게 간단하지 않습니다.

코드를 잘 못 작성하면 메모리가 완전 난잡해집니다.

대신 Swift는 C에서 제공하지 않는 다양한 추상화 기능과 클로저, 제네릭 등을 지원합니다 이러한 추상화 기능은 간단하게 구현되어 있지 않으며 명시적으로 malloc을 호출하듯이 명확한 비용을 알 수 없습니다

What is Performance?

만약 어떤 도구가 있어서 프로그램을 그 도구에 집어넣으면 하나의 숫자가 출력되고 그 숫자로 프로그램의 성능을 모두 알 수 있다면 얼마나 좋을까요

Safari의 성능 점수가 9.2라고 출력되는것 같이 표시하고싶지만 그렇게 할 수는 없습니다 성능은 다차원적이고 상황에 따라 달라진다.

우리는 보통 거시적인 차원에서 성능에 대해 다룹니다 데몬이 너무 많은 자원을 사용하고 있거나 UI를 클릭할 때 지나치게 느리게 반응하거나 앱이 계속 중단되는 경우죠 이러한 문제를 조사할 때는 보통 하향식으로 접근합니다

Macroscopic goals

  • Reduce latency
  • Reduce power consumption
  • Stay within memory limits

대부분 경우 알고리즘을 개선하여 해결합니다.

로우레벨의 성능까지는 굳이 살펴보지않죠

하지만 가끔은 로우레벨까지봐야함

알고리즘 수준에서 더이상 개선할 부분이 없고 그냥 느리게 실행되는 것일수있음

코드가 실제로 어떻게 실행되는지 이해해야하고 그렇게하려면 상향식 접근방식이 필요함

로우레벨 성능은 대체로 4가지사항에 크게 영향을 받음

효율적으로 최적화되지않은 수많은 호출

데이터표현하는 방식에 시간 또는 메모리 소비

메모리 할당에 많은시간 소비

불필요하게 값을 복사하고 파괴하며 시간소비

Swift에는 강력한 옵티마이저가 있습니다 몇 가지 성능 문제는 발생할 일이 없는데 컴파일러가 효과적으로 제거해 주기 때문입니다 최적화에는 한계가 있습니다 코드를 작성하는 방식은 옵티마이저의 최적화 성능에 큰 영향을 줄 수 있습니다.

Low-level principles

로우레벨 성능에 대해 생각할때 고려해야하는 여러 기본원칙

Function calls

함수호출에 연관된 비용은 4가지임

그중 우리는 3가지 작업을함 먼저호출할때 인수를 설정

호출하는 함수의 주소알아야 내야하고

함수의 로컬상태를 저장할 공간을 할당해야함

4번째작업은 우리가 안하고 최적화에 제약을 검, 호출자에서도, 호출하는함수에서도 그럴수있음

 

 

이렇게 4가지임

 

  • Argument passing(인수전달)

Calling convention

이 비용은 2개수준에서 발생함

로우레벨에서는 호출시 호출규칙에 따라 인수를 올바른 위치에 배치해야함

최신프로세서에서는 보통 이비용을 레지스터이름을 바꿔숨길수있으므로 실질적으로 큰영향을 주진않음

Copies

하지만 높은 수준에서는 함수의 소유권 규칙을 추옺ㄱ하기위해 컴파일러가 값을 복사해야할수도있음

프로파일의 호출자또는 호출함수에서 소유권의 추가획득과 해제로 나타나고는 합니다.

Call dispatch

함수결정과 최적화에 미치는영향은 모두 같은이유에서 발생함

컴파일타임에 어떤함수를 호출하는지 정확히알고있는가?

알고있다면 정적디스패치를 사용하는것이고 아니면 동적디스패치를 사용하는것임

정적 디스패치는 더 효율적이고 프로세서 수준에서 더 빠르게 작동하기도 하지만 무엇보다 중요한 것은 컴파일 타임에 다양하게 인라인 처리나 제네릭 구체화처럼 상당한 최적화가 이루어진다는 점입니다 컴파일러가 함수 정의를 알 수 있다면 말이죠 하지만 동적 디스패치는 다형성과 같은 강력한 추상화 기능을 제공합니다

Swift에서는 특정종류의 호출만 동적 디스패치를 사용하며 호출대상의 정의에서 이를 확인할 수 있음

- opaque함수 값 호출

- 재정의 가능한 클래스 메서드 호출

- 프로토콜 요구사항 호출

- objective-C 또는 가상 C++ 메서드 호출

이외에는 모두 스테틱 입니다.

예를들어 프로토콜 타입의 값을 업데이트하는 호출이있을때

 

호출의 타입은 메서드가 선언된 장소에 따라 달라짐

프로토콜의 메인 바디 안에 선언된 경우(updateAll함수) 프로토콜 요구사항이며 호출시 동적디스패치를 사용

하지만 프로토콜 extension안에서 선언되면 호출은 정적디스패치 사용

이는 의미론적으로도 성능면에서 아주 중요한 차이가 있음

 

  • Local allocation

로컬상태저장을 위한 메모리할당

함수가 실행되려면 메모리가 필요합니다 일반적인 동기 함수이므로 이 메모리를 C 스택에 할당합니다 C 스택에 공간을 할당하려면 스택 포인터에서 할당할 공간만큼 빼면 됩니다

이를 컴파일하면 어셈블리 코드의 함수시작과 끝부분에서 스택포인터를 조작함

함수에 진입하면 스택포인터가 C스택을 가리키고있음

먼저 스택포인터에서 값을 뺴면서시작 어셈블리코드에서 208바이트만큼 빼고잇음

이렇게하면 기존에 CallFrame이라고 부르던 공간이 할당되며 함수실행공간이 마련됨

이제 함수본문 실행가능

반환하기직전에 스택포인터에 다시 208바이트를더해 이전에 할당한 메모리를 할당해제함

 

 

Memory allocation

전통적으로 메모리는 세 종류로 구분됩니다

물론 컴퓨터 입장에서는 결국 동일한 RAM 상의 메모리입니다 하지만 프로그램에서 우리는 서로 다른 패턴으로 할당하고 사용하죠 이러한 점은 운영 체제에 중요한 사항이며 성능 면에서도 중요합니다

 

  • Global

프로그램 로드시 할당되고 초기화

비용은 거의없음

글로벌 메모리의 큰 단점은 고정된 크기의 메모리를 사용하는 특정 패턴에서만 사용 가능하며 프로그램을 실행하는 동안 메모리가 유지된다는 점입니다 이러한 특성은 글로벌 변수와 정적 멤버 변수에는 적합하지만 그 외의 경우에는 적합하지 않죠

 

  • Stack

스택 메모리도 비용이 아주 적지만 특정 패턴에만 사용할 수 있습니다 스택 메모리의 경우 메모리의 범위가 한정되어야 합니다 현재 함수의 특정 지점부터는 해당 메모리가 더 이상 사용되지 않도록 해야 합니다 일반적인 로컬 변수에 적합합니다

 

  • Heap

힙 메모리는 매우 유연합니다 언제든지 할당할 수 있으며 이후 언제든지 할당을 해제할 수 있습니다

이러한 유연성 때문에 다른 종류의 메모리에 비해 할당 및 할당 해제 시 훨씬 더 많은 비용이 필요합니다

힙은 클래스 인스턴스 같이 특히 자주 사용되는 용례가 있고 정적 수명 제한이 분명하지 않아 다른 메모리를 사용하기 어려울 때도 사용됩니다

종종 힙 메모리를 할당할 때 메모리가 소유권을 공유하게 될 때가 있으며 이는 같은 메모리를 여러 레퍼런스가 개별적으로 참조하는 것을 의미하죠 Swift는 레퍼런스 카운팅으로 할당의 수명을 관리합니다

Swift에서 레퍼런스 카운트를 늘리는 것을 retain 이라 하고 레퍼런스 카운트를 줄이는 행위는 release한다고 합니다

Memory layout

메모리에 저장된 형태에대해 이야기할때 value라는 단어를 그대로사용하는경우가 있는데 혼란스러울수있으므로

더 기술적인 용어인 representation(표현)이란 단어를 사용

변수 array는 메모리의 이름으로 버퍼 객체에 대한 레퍼런스를 가지며 이는 두 double값의 표현으로 초기화됨

인라인 표현이라는 용어를 사용하여 포인터를 따라가지 않고 확인할 수 있는 부분의 표현을 나타내겠습니다

따라서 변수 array의 인라인 표현은 하나의 버퍼 레퍼런스입니다 버퍼에 담긴 내용이 무엇인지는 고려하지 않는 거죠 표준 라이브러리의 MemoryLayout이 인라인 표현을 측정해 줍니다 array의 경우 단지 8바이트로 64비트 포인터 하나의 크기입니다

 

Swift에서 모든 값은 특정 컨텍스트 안에 담깁니다 로컬 범위에는 그 안에서 사용되는 모든 값이 담깁니다 로컬 변수, 중간 결과 등이죠 구조체와 클래스에는 모든 저장 속성이 담겨 있습니다 배열과 딕셔너리는 버퍼를 통해 그 요소를 모두 담고 있는 등 그런 식이죠

 

예를 통해 살표보겠습니다.

array는 로컬 변수입니다 배열 값이 있고 로컬 범위에 속해 있습니다

로컬 범위에서는 가능하면 인라인 표현을 함수의 CallFrame에 배치합니다 여기서도 그렇게 동작하죠 이 CallFrame 어딘가에 Double 배열의 인라인 표현을 위한 공간이 있습니다

Array는 구조체이며 구조체의 인라인 표현은 모든 저장 속성의 인라인 표현입니다

결국 Array의 저장 속성은 하나이며 클래스 레퍼런스입니다 그리고 클래스 레퍼런스는 객체에 대한 단순한 포인터입니다

실제로 CallFrame에는 이 포인터만 저장됩니다

Swift에서 구조체, 튜플 및 열거형은 모두 인라인 공간을 사용합니다 포함된 모든 것이 컨테이너 안에 인라인 방식으로 정리되며 일반적으로 선언된 순서를 따릅니다

클래스 및 액터는 외부 공간을 사용합니다 포함하고 있는 모든 것을 객체 안에 인라인 방식으로 저장하고 컨테이너는 해당 객체에 대한 포인터만 저장합니다

이 차이는 성능에 큰 영향을 줍니다

Value copying

Array의 인라인 표현이 버퍼 객체에 대한 레퍼런스라고 설명했습니다

이러한 레퍼런스는 레퍼런스 카운팅을 통해 관리됩니다 컨테이너가 Array 값의 소유권을 가지고 있다는 것은 다시 말해서 컨테이너에 값을 저장하는 과정에서 안에 담긴 배열 버퍼의 소유권을 획득한 것입니다

이제 컨테이너는 그러한 획득과 해제의 균형을 책임지고 유지해야 합니다 적어도 컨테이너가 사라질 때는 그러한 작업이 발생해야 합니다

Swift에서 값이나 변수를 사용할 때는 언제나 이 소유권 시스템과 상호작용하며 이는 메모리 안전의 핵심입니다 소유권 상호작용은 세 가지 유형이 있습니다 값이 소모될 수 있고 변경되거나 대여될 수 있습니다

값을 사용하는 방법엔 3가지가있음

 

  • Consume

값을 소모하는 것은 표현의 소유권을 한 곳에서 다른 곳으로 넘긴다는 의미입니다 자연스럽게 값을 소모하는 가장 중요한 작업은 메모리에 값을 할당할 때입니다

 

첫 번째 변수의 값으로 두 번째 변수를 초기화하려면 값의 소유권을 다시 새로운 변수로 이전해야 합니다 하지만 이제 초기 값의 표현식이 기본적으로 새 값을 생성하지 않습니다 단순히 기존 변수를 참조하죠 이 변수의 값을 그냥 훔칠 수는 없습니다 앞으로 더 사용할 수도 있으니까요

독립된 값을 얻으려면 기존 변수의 현재 값을 복사해야 합니다 값이 배열이므로 복사하려면 값의 버퍼 소유권을 획득해야 합니다

이러한 작업은 자주 최적화됩니다 기존 변수가 앞으로 사용되지 않음을 컴파일러가 확인할 수 있다면 값을 복사하지 않고 이전할 수 있습니다

 

consume 연산자를 사용해 이를 명시적으로 요청할 수도 있습니다 이 값이 명시적으로 소모된 이후 시점에 변수를 사용하려 하면 Swift는 오류를 표시하며 값이 더 이상 존재하지 않는다고 말해줍니다

 

  • Mutate

값을 변경한다는 것은 변경 가능한 변수에 저장된 현재 값의 소유권을 일시적으로 가져오는 겁니다 소유권 consume과 가장 큰 차이는 값이 변경된 후에도 변수가 소유권을 가져야 한다는 겁니다

이처럼 값을 변경하는 메서드를 호출하면 변수가 현재 가지고 있는 값의 소유권을 메서드에 넘겨주게 됩니다 Swift는 호출 도중에 변수를 어떤 방법으로든 동시에 사용하지 못하도록 방지합니다

메서드가 완료되면 새 값의 소유권을 변수에 다시 넘겨줍니다 이렇게 함으로써 변수가 값의 소유권을 보유한다는 사실을 유지할 수 있습니다

 

  • Borrow

값을 대여하는 것은 다른 누구도 소모하거나 변경하지 못하게 하는 것입니다 기본적으로 값을 그저 읽고 싶을 때 적합한 작업입니다 이때 중요한 것은 다른 곳에서 값을 변경하거나 파괴하지 않는 것이니까요

 

값을 대여하려면 동시에 값을 변경하거나 소모하려는 작업이 없다는 것을 Swift가 검증할 수 있어야 합니다

 

클래스 속성에 있는 공간의 경우 이 속성이 대여 중에 변경되지 않는다는 것을 Swift가 검증하기 어려우므로 방어적으로 복사해야 할 수 있습니다 Swift는 이 영역에서 활발하게 개선이 이루어지고 있습니다 옵티마이저도 개선되고 있으며 복사하지 않고 명시적으로 값을 빌릴 수 있는 기능도 새로 추가되고 있습니다

 

값을 복사한다는 것의 실질적인 의미는 무엇일까?

값의 인라인 표현에 따라 다릅니다 값을 복사한다는 것은 인라인 표현을 복사한다는 말이므로 독립적인 소유권이 있는 새로운 인라인 표현을 얻는 것입니다

따라서 클래스 값을 복사하면 레퍼런스의 소유권을 복사한다는 의미로 단순히 참조하는 객체의 소유권을 획득하는 것입니다

구조체 값을 복사하면 구조체의 모든 저장 속성을 재귀적으로 복사합니다

 

인라인 공간과 외부 공간을 선택할 때 실질적인 절충이 필요합니다

인라인 공간은 힙에 메모리를 할당하지 않으며 작은 유형에 적합합니다 더 큰 유형의 경우 복사에 드는 비용 때문에 성능이 크게 저하될 수 있습니다 복사를 자주 해야 한다면 말입니다

성능의 최적화를 위한 정해진 규칙은 없습니다

 

큰 구조체를 복사할 때 비용은 두 부분에서 발생합니다

 

첫째, 값 유형을 복사할 때 단순히 비트만 복사하지는 않습니다

이 저장 속성 3개는 모두 객체 레퍼런스로 표현되며 둘러싸고 있는 구조체를 복사할 때 소유권을 획득해야 합니다 이것을 클래스로 만들면 복사할 때 클래스 객체의 소유권을 획득해야 하겠지만 이를 구조체로 복사하면 여전히 이 3개 필드의 소유권을 각각 획득하게 됩니다

 

또한 이 값의 각 사본에 모든 저장 속성을 위한 공간이 필요하게 됩니다 따라서 이 값을 많이 복사해야 하는 경우 많은 메모리를 사용하게 되죠 이 유형이 대신 외부 공간을 사용한다면 모든 사본이 같은 객체를 참조하므로 메모리가 재사용될 것입니다 마찬가지로 고정된 규칙은 없지만 생각해 볼 만한 방법입니다

 

Swift에서는 value semantic을 채택해 유형을 작성할 것을 권장합니다

그러면 값의 사본이 원본과 전혀 관련이 없는 것처럼 동작합니다 구조체는 이렇게 동작하지만 항상 인라인 공간을 사용합니다 클래스 유형은 외부 공간을 사용하며 기본적으로 레퍼런스 의미를 기반으로 합니다 외부 공간을 사용하면서 값 의미를 활용하려면 클래스를 구조체로 둘러싸고 Copy-on-Write를 사용합니다 표준 라이브러리에서는 Swift의 모든 기본적인 데이터 구조인 배열이나 딕셔너리, 문자열 등에 이 기법을 사용합니다

 

 

정리한 내용이 너무길어서

다음내용인 Putting it together 목차는

스위프트 주요기능에 대해 살펴보는 내용으로

다음 글에 이어서 작성할게요!

 

반응형