새소식

iOS/Swift

protocol을 채택하지 않는 struct도 3word가 넘으면 데이터가 heap에 저장될까?

  • -

Swift 개발자라면 한 번 쯤 봤을법한 아래 영상을 리마인드할 겸 다시 리뷰하던 중 이런 궁금증이 생겼다.

https://developer.apple.com/videos/play/wwdc2016/416/

 

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

"protocol을 채택하지 않는 struct도 3word가 넘으면 데이터가 heap에 저장될까?"

 

사실 예전에 이 내용을 처음 봤을 때 좀 오해를 해서 struct에 저장하는 데이터가 3word를 넘어가면 무조건 heap에 저장하는 줄 알았다. 그래서 3word면 너무 작은 거 아닌가 싶었는데 이번에 다시 보니 그게 아니고 struct가 protocol을 채택할 때 protocol type 데이터의 크기가 일정하지 않기 때문에 existential container를 사용하는 것이고 그 때 3word를 넘어가면 heap에 저장한다는 내용이었던 것이다.

 

그래서 그럼 protocol을 채택할 때는 전부 stack에 저장한다는 것인지, 만약 그렇다면 스택 오버플로우의 위험이 있지 않나 하는 의문이 생겼고 실제 확인을 위해 실험에 들어갔다.

 

Existential Container 눈으로 확인하기

먼저 existential container의 존재를 메모리에서 확인하기 위해 영상에 나오는 예제를 사용하여 다음과 같이 작성했다.

protocol Drawable { func draw() }

struct Point: Drawable {
    var x, y: Int
    func draw() {
    }
}

struct Line: Drawable {
    var x1, y1, x2, y2: Int
    func draw() {
    }
}

func f() {
    var drawables: [Drawable] = [
        Point(x: 0x11111111, y: 0x22222222),
        Line(x1: 0x33333333, y1: 0x44444444, x2: 0x55555555, y2: 0x66666666)
    ]
}

f()

그리고 함수가 리턴하기 전에 브레이크를 잡아서 메모리를 확인해봤다.

frame variable -L drawables

현재 스택 프레임의 로컬 변수를 출력하는 위 명령어로 drawables 변수의 주소를 알아내고 

memory read 0x000000016aef5898

그 주소를 메모리 내용을 보여주는 위 명령어에 입력하여 drawables 로컬 변수에 저장된 내용을 확인했다.

영상의 내용에 따르면 여기에는 drawables 배열이 실제로 저장되어 있는 힙 메모리 주소가 들어있어야 하고 테스트 장비는 Apple M1 Max CPU가 장착되어 있는 64bit 머신이므로 그 주소는 8바이트여야 한다.

위 캡쳐 이미지에서 drawables의 힙 메모리 주소는 0x600002e64540이 그 주소이다.(Little Endian 이므로 바이트를 역순으로 읽어야 한다.)

다시 한 번 이 주소로 메모리 내용을 읽어본다. 기본으로 32바이트만 보여주는데 필요한 내용이 다 보이지 않아서 128바이트를 보여주도록 끝 주소도 입력해주었다.

memory read 0x600002e64540 0x600002e64540+128

메모리 주소 앞부분에는 뭔가 array의 meta data(영상에 따르면 reference count가 포함될 듯)가 32바이트 정도 차지하고 있는 것 같고 첫번째 element인 Point Type의 existential container가 노란색으로 표시된 5개의 word인 것으로 보이고 초록색으로 표시된 것은 두번째 element인 Line Type의 existential container인 것 같다. Point는 2 바이트로 3 word (24 바이트) 이내이기 때문에 value buffer에 바로 저장된 것을 볼 수 있다. 그러나 Line은 4 바이트로 value buffer의 크기를 넘어가기 때문에 value buffer의 첫번째 word에 0x600000b69e60이라는 실제 Line Type 구조체 데이터가 저장된 힙 메모리 주소만 저장되어 있는 것을 확인할 수 있다. 그리고 두개의 word가 비어 있다. 두개의 existential container의 마지막 2 word는 VWT와 PWT의 주소인 것으로 추정된다.

그러면 다시 Line Type element의 데이터를 확인하기 위해 0x600000b69e60 주소의 메모리를 확인해보자.

memory read 0x600000b69e60 0x600000b69e60+64

이번에는 16바이트 정도의 meta data로 추정되는 것들이 있고 그 뒤부터 Line Type의 데이터가 들어있는 것을 볼 수 있다. 참고로 스택에서는 지정된 메모리 주소부터 바로 데이터 값이 시작되는데 유독 힙 메모리 주소에는 바로 데이터가 시작되지 않고 뭔가 다른 값들이 들어있는 것을 볼 수 있었는데 reference count 등을 관리하기 위한 heap memory 구조의 특징이 아닐까 싶다.

여기까지가 영상에 설명된 내용을 토대로 검증해 본 내용이다.

그럼 Protocol 을 채택하지 않은 struct는?

그럼 이제부터 진짜 궁금증을 해결하기 위해 코드를 조금 바꿔보자.

struct Point: Drawable {
    var x, y: Int
    func draw() {
    }
}

struct Line {
    var x1, y1, x2, y2: Int
    func draw() {
    }
}

struct DrawableLine: Drawable {
    var x1, y1, x2, y2: Int
    func draw() {
    }
}

func f() {
    var point = Point(x: 0x11111111, y: 0x22222222)
    var drawableLine: Drawable = DrawableLine(x1: 0x33333333, y1: 0x44444444, x2: 0x55555555, y2: 0x66666666)
    var line = Line(x1: 0x33333333, y1: 0x44444444, x2: 0x55555555, y2: 0x66666666)
}

f()

Line구조체가 더 이상 Drawable이 아니다. 대신 DrawableLine이라는 새로운 타입이 Drawable을 채택하도록 했다.

역시 브레이크를 잡고 메모리를 출력해본다. 이번에는 현재 stack pointer부터 현재 function pointer까지 전부 출력해봤다. 참고로 스택 메모리는 주소값이 큰 쪽에서 작은 쪽으로 거꾸로 자라나는데 fp, 즉 function pointer라는 레지스터는 스택 메모리에서 현재 함수의 시작위치를 가리키는 포인터로서 이 지점부터 로컬 변수가 스택에 쌓이게 되고, sp, 즉  stack pointer는 현재 스택의 탑을 가리키는 포인터이다.

노란색 부분의 point 변수 16 바이트, 주황색 부분의 drawableLine의 existential container 5 word (40 바이트), 초록색 부분의 line 변수 32 바이트가 스택 메모리에 저장된 것을 볼 수 있다.

참고로 주황색과 초록색 사이 8 바이트는 16 바이트 memory alignment를 위해 8 바이트 패딩이 된 것으로 볼 수 있을 것 같다. 나도 실험과정에서 알게 된 사실이지만 Apple M1 Max 칩은 64비트이지만 8바이트가 아닌 16바이트 memory alignment를 사용하고 있다. 그 이유는 SIMD(Single Instruction Multiple Data) 명령어의 처리 성능을 높이기 위한 것이라고 한다. SIMD (Single Instruction Multiple Data) 명령어는 하나의 명령어로 여러 개의 데이터를 동시에 처리하는 컴퓨터 아키텍처 기술이며, 이 명령어는 주로 과학 및 그래픽 처리, 멀티미디어 애플리케이션 등에서 사용되며, 벡터화된 데이터를 처리하는 데 특히 유용하다고 한다.

 

protocol type으로 선언된 drawableLine은 existential container를 사용했지만 protocol을 채택하지 않은 struct type인 line과, point는 모두 existential container 없이 스택 메모리에 바로 저장되었고 특히 line은 3 word가 넘었지만 따로 힙 메모리에 할당되지 않고 그대로 스택 메모리가 사용된 것을 확인할 수 있었다. 추가적으로 drawableLine 변수를 Drawable 타입이 아닌 DrawableLine 타입으로 선언해도 existential container 없이 스택 메모리에 바로 저장되는 것을 확인할 수 있다.

결론

결국 다음과 같은 결론을 낼 수 있을 것 같다.

existential container는 protocol 자체가 type으로 사용된 경우에만 사용되는 것이기 때문에
protocol을 채택하지 않는 struct는 3 word가 넘어도 스택에 모두 저장된다.

One more thing...

 

처음에 의문을 가졌던 이유 중 하나는 엄청나게 큰 struct를 스택 메모리에 그대로 저장하면 스택 오버플로우가 발생하지 않을까 하는 우려 때문이었다. 그러나 테스트 해 본 결과 Int 프로퍼티를 약 210여개 정도 가지고 있는 struct를 30개 정도 로컬변수로 가지고 있는 함수 f()를 재귀호출해보니 50여 번 정도 재귀호출되었을 때 스택 오버플로우가 발생했다. 그리고 위 마지막 예제 코드에서 사용한 f() 함수를 무한 재귀호출했을 때는 약 44000여 번 재귀호출되었을 때 스택 오버플로우가 발생했다. 물론 시스템마다 다르긴 할거고 이 정도로 하드코어하게 사용하는 일은 드물거라고 생각이 되지만 struct를 너무 크게 구성하면 콜스택의 깊이가 깊어질 경우 스택 오버플로우도 일어날 수 있다는 정도는 염두에 두어도 좋을 것 같다. 물론 대부분의 경우 String이나 Array 프로퍼티를 섞어서 구성하지, 순수 스택에만 저장되는 struct를 그렇게까지 크게 만들 일은 거의 없을 거라서 그런 경우를 만날 가능성은 매우 매우 희박할 거라 생각한다.

반응형

'iOS > Swift' 카테고리의 다른 글

캡쳐리스트  (0) 2023.11.17
[Tip] RxSwift 콜스택만 잔뜩있는 크래시 로그로 디버깅하기  (0) 2023.11.16
some, any  (0) 2023.07.25
LetSwift in 판교 2차 후기  (0) 2020.01.16
iOS에서의 이미지 리사이징에 대하여  (1) 2019.09.27
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.