[Swift] Understanding Swift Performance / WWDC 16

2023. 3. 9. 20:46🍏/Swift

 

 

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

위 링크의 비디오를 보고 정리한 글입니다.

Heap영역은 Stack보다 더 많은 코스트를 사용한다.
Stack 차원으로 관리를 하면 성능향상에 도움이 된다.

 

Class와 Struct의 관점에서 Allocation, Reference counting 을 확인하고 사용.

 

 

 

 

 


How to More Better Performance in swift ?

"Is my instance going to be allocated on the stack or the heap? When I pass this instance around, how much reference counting overhead am I going to incur? When I call a method on this instance, is it going to be statically or dynamically dispatched?"

- "인스턴스가 스택에 할당될 것인가 아니면 힙에 할당될 것인가?"라고 자문해야 합니다. 이 인스턴스를 전달할 때 얼마나 많은 참조 계산 오버헤드가 발생할까? 이 인스턴스에서 메서드를 호출할 때 정적으로 디스패치될 것인가, 아니면 동적으로 디스패치될 것인가?"라고 생각해보세요.

  If we want to write fast Swift code, we're going to need to avoid paying for dynamism and runtime that we're not taking advantage of.

- 빠른 Swift 코드를 작성하려면 활용하지도 않는 동적성과 런타임에 대한 비용을 지불하지 않아야 합니다.


Stack ? Heap ?

Swift automatically allocates and deallocates memory on your behalf. Some of that memory it allocates on the stack.

- Swift는 사용자를 대신해 자동으로 메모리를 할당하고 할당 해제합니다. 그 메모리 중 일부는 스택에 할당합니다.

The stack is a really simple data structure. You can push onto the end of the stack and you can pop off the end of the stack. Because you can only ever add or remove to the end of the stack, we can implement the stack -- or implement push and pop just by keeping a pointer to the end of the stack. And this means, when we call into a function -- or, rather -- that pointer at the end of the stack is called the stack pointer. And when we call into a function, we can allocate that memory that we need just by trivially decrementing the stack pointer to make space. And when we've finished executing our function, we can trivially deallocate that memory just by incrementing the stack pointer back up to where it was before we called this function. Now, if you're not that familiar with the stack or stack pointer, what I want you to take away from this slide is just how fast stack allocation is. It's literally the cost of assigning an integer.

- 스택은 매우 간단한 데이터 구조입니다. 스택의 끝을 밀면 스택의 끝에서 튀어나올 수 있습니다. 스택의 끝에만 추가하거나 제거할 수 있기 때문에 스택의 끝을 가리키는 포인터를 유지하는 것만으로 스택을 구현하거나 푸시 앤 팝을 구현할 수 있습니다. 즉, 함수를 호출할 때 스택 끝에 있는 포인터를 스택 포인터라고 부릅니다. 함수를 호출할 때 스택 포인터를 조금만 줄여서 공간을 확보하면 필요한 메모리를 할당할 수 있습니다. 그리고 함수 실행이 끝나면 스택 포인터를 함수를 호출하기 전의 위치로 다시 증가시키는 것만으로 해당 메모리를 간단하게 할당 해제할 수 있습니다. 스택이나 스택 포인터에 익숙하지 않으시다면 이 슬라이드를 통해 스택 할당이 얼마나 빠른지 알아두시기 바랍니다. 말 그대로 정수를 할당하는 데 드는 비용입니다.

So, this is in contrast to the heap, which is more dynamic, but less efficient than the stack. The heap lets you do things the stack can't like allocate memory with a dynamic lifetime.

따라서 스택보다 동적이지만 효율성이 떨어지는 힙과는 대조적입니다. 힙을 사용하면 동적 수명으로 메모리를 할당하는 등 스택이 할 수 없는 작업을 수행할 수 있습니다.

But that requires a more advanced data structure. So, if you're going to allocate memory on the heap, you actually have to search the heap data structure to find an unused block of the appropriate size. And then when you're done with it, to deallocate it, you have to reinsert that memory back into the appropriate position.

하지만 이를 위해서는 더 고급 데이터 구조가 필요합니다. 따라서 힙에 메모리를 할당하려면 실제로 힙 데이터 구조를 검색하여 적절한 크기의 사용되지 않는 블록을 찾아야 합니다. 그리고 그 작업이 끝나면 할당을 해제하기 위해 해당 메모리를 적절한 위치에 다시 삽입해야 합니다.

So, clearly, there's more involved here than just assigning an integer like we had with the stack. But these aren't even necessarily the main costs involved with heap allocation. Because multiple threads can be allocating memory on the heap at the same time, the heap needs to protect its integrity using locking or other synchronization mechanisms. This is a pretty large cost. If you're not paying attention today to when and where in your program you're allocating memory on the heap, just by being a little more deliberate, you can likely dramatically improve your performance.

따라서 여기에는 스택에서와 같이 정수를 할당하는 것보다 더 많은 작업이 수반되는 것이 분명합니다. 하지만 이것이 힙 할당과 관련된 주요 비용은 아닙니다. 여러 스레드가 동시에 힙에 메모리를 할당할 수 있기 때문에 힙은 잠금이나 기타 동기화 메커니즘을 사용하여 무결성을 보호해야 합니다. 이는 꽤 큰 비용입니다. 현재 프로그램에서 힙에 메모리를 할당하는 시기와 위치에 주의를 기울이지 않고 있다면 조금만 더 신중을 기하는 것만으로도 성능을 크게 향상시킬 수 있습니다.

We saw that classes are more expensive to construct than structs because classes require a heap allocation.

클래스에는 힙 할당이 필요하기 때문에 구조체보다 클래스를 구성하는 데 비용이 더 많이 든다는 것을 알 수 있었습니다.

Because classes are allocated on the heap and have reference semantics, classes have some powerful characteristics like identity and indirect storage. But, if we don't need those characteristics for abstraction, we're going to better -- if we use a struct.

클래스는 힙에 할당되고 참조 시맨틱을 가지므로, 클래스는 신원 및 간접 저장과 같은 몇 가지 강력한 특성을 가지고 있습니다. 하지만 추상화를 위해 이러한 특성이 필요하지 않다면 구조체를 사용하는 것이 더 낫습니다.

And structs aren't prone to the unintended sharing of state like classes are.

그리고 구조체는 클래스처럼 의도하지 않은 상태 공유가 발생하지 않습니다.

String isn't particularly a strong type for this key. I'm using it to represent this configuration space, but I could just as easily put the name of my dog in that key. So, not a lot of safety there. Also, String can represent so many things because it actually stores the contents of its characters indirectly on the heap. So, that means every time we're calling into this makeBalloon function, even if we have a cache hit, we're incurring a heap allocation.

문자열은 이 키에 특별히 강력한 유형이 아닙니다. 이 구성 공간을 나타내기 위해 사용하고 있지만 이 키에 강아지의 이름을 넣을 수도 있습니다. 따라서 그다지 안전하지 않습니다. 또한 문자열은 실제로 문자의 내용을 힙에 간접적으로 저장하기 때문에 많은 것을 나타낼 수 있습니다. 따라서 이 makeBalloon 함수를 호출할 때마다 캐시를 사용하더라도 힙 할당이 발생한다는 뜻입니다.

structs are first class types in Swift, they can be used as the key in our dictionary.

구조체는 Swift의 첫 번째 클래스 유형이므로 사전에서 키로 사용할 수 있습니다.

Now, when we call the makeBalloon function, if we have a cache hit, there's no allocation overhead because constructing a struct like this attributes one doesn't require any heap allocation. It can be allocated on the stack. So, this is a lot safer and it's going to be a lot faster.

이제 makeBalloon 함수를 호출할 때 캐시 히트가 발생하면 이와 같은 구조체를 구성하면 힙 할당이 필요하지 않으므로 할당 오버헤드가 없습니다. 스택에서 할당할 수 있습니다. 따라서 훨씬 더 안전하고 훨씬 더 빠릅니다.

String 은 Hashable한 값이 아니기 때문에 키로써는 부적절하다는 의미. 
(호출 할 때 마다 힙 할당 오버헤드가 발생) 때문에
Hashable한 Struct 를 사용해라. 또한 다른 이유로는 Struct는 스택에서 관리되는 대상이기 때문에 Cost가 낮아서 Performance상으로 더 좋다.


Class ? Struct ? Reference Counting ?

Let's move on to our next dimension of performance, reference counting.

성능의 다음 차원인 참조 카운팅으로 넘어가 보겠습니다.

So, I glossed over a detail when we were talking about heap allocation. How does Swift know when it's safe to deallocate memory it allocated on the heap? Well, the answer is Swift keeps a count of the total number of references to any instance on the heap. And it keeps it on the instance itself. When you add a reference or remove a reference, that reference count is incremented or decremented. When that count hits zero, Swift knows no one is pointing to this instance on the heap anymore and it's safe to deallocate that memory.

힙 할당에 대해 이야기할 때 세부적인 내용을 간략히 설명했습니다. Swift는 힙에 할당된 메모리를 언제 해제해도 안전한지 어떻게 알 수 있을까요? 답은 Swift가 힙에 있는 모든 인스턴스에 대한 총 참조 수를 카운트한다는 것입니다. 그리고 이를 인스턴스 자체에 보관합니다. 참조를 추가하거나 참조를 제거하면 해당 참조 수가 증가하거나 감소합니다. 참조 카운트가 0이 되면 Swift는 힙에서 더 이상 이 인스턴스를 가리키는 사람이 없으므로 해당 메모리를 할당 해제해도 안전하다는 것을 알 수 있습니다.

The key thing to keep in mind with reference counting is this is a really frequent operation and there's actually more to it than just incrementing and decrementing an integer. First, there's a couple levels of indirection involved to just go and execute the increment and decrement. But, more importantly, just like with heap allocation, there is thread safety to take into consideration because references can be added or removed to any heap instance on multiple threads at the same time, we actually have to atomically increment and decrement the reference count. And because of the frequency of reference counting operations, this cost can add up.

참조 계수에서 명심해야 할 핵심 사항은 이것이 매우 빈번한 연산이며 실제로 정수를 늘리거나 줄이는 것 이상의 의미가 있다는 것입니다. 먼저, 증가와 감소를 실행하기 위해서는 몇 가지 수준의 방향 전환이 필요합니다. 하지만 더 중요한 것은 힙 할당과 마찬가지로 여러 스레드에서 동시에 힙 인스턴스에 참조를 추가하거나 제거할 수 있기 때문에 스레드 안전성을 고려해야 하며, 실제로 참조 수를 원자 단위로 증가 및 감소시켜야 한다는 점입니다. 그리고 참조 카운팅 작업의 빈도로 인해 이 비용은 합산될 수 있습니다.

So, let's go back to our point class and our program and look at what Swift is actually doing on our behalf. So, here now we have, in comparison, some generated pseudocode. We see our point has gained an additional property, refCount. And we see that Swift has added a couple calls to retain -- or a call to retain and a couple calls to release. Retain is going to atomically increment our reference count and release is going to atomically decrement our reference count. In this way Swift will be able to keep track of how many references are alive to our point on the heap. All right. And if we trace through this quickly, we can see that after constructing our point on the heap, it's initialized with a reference count of one because we have one live reference to that point. As we go through our program and we assign point1 to point2, we now have two references and so Swift has added a call to atomically increment the reference count of our point instance. As we keep executing, once we've finished using point1, Swift has added a call to atomically decrement the reference count because point1 is no longer really a living reference as far as it's concerned. Similarly, once we're done using point2, Swift has added another atomic decrement of the reference count. At this point, there's no more references that are making use of our point instance and so Swift knows it's safe to lock the heap and return that block of memory to it.

이제 포인트 클래스와 프로그램으로 돌아가서 Swift가 실제로 우리를 대신해 무엇을 하는지 살펴봅시다. 이제 여기에는 비교를 위해 생성된 의사 코드가 있습니다. 포인트가 refCount라는 추가 프로퍼티를 얻은 것을 볼 수 있습니다. 그리고 Swift가 retain에 두 개의 호출을 추가했거나, retain에 두 개의 호출과 release에 두 개의 호출을 추가했음을 알 수 있습니다. Retain은 참조 수를 원소 단위로 증가시키고 release는 참조 수를 원소 단위로 감소시킵니다. 이런 식으로 Swift는 힙에서 현재 시점까지 얼마나 많은 참조가 살아 있는지 추적할 수 있습니다. 좋아요. 이 과정을 빠르게 추적해 보면 힙에 지점을 생성한 후 해당 지점에 대한 라이브 참조가 하나 있기 때문에 참조 카운트가 1로 초기화되는 것을 볼 수 있습니다. 프로그램을 진행하면서 point1을 point2에 할당하면 이제 참조가 두 개가 되므로 Swift는 포인트 인스턴스의 참조 수를 원자 단위로 증가시키는 호출을 추가했습니다. 실행을 계속하면서 point1 사용을 마치면 Swift는 point1이 더 이상 살아있는 참조가 아니므로 참조 수를 원자 단위로 감소시키는 호출을 추가했습니다. 마찬가지로, point2 사용을 마치면 Swift는 참조 수를 원자 단위로 감소시키는 호출을 추가했습니다. 이 시점에서 포인트 인스턴스를 사용하는 참조가 더 이상 존재하지 않으므로 Swift는 힙을 잠그고 해당 메모리 블록을 반환해도 안전하다는 것을 알 수 있습니다.

So, what about structs? Is there any reference counting involved with structs? Well, when we constructed our point struct, there was no heap allocation involved. When we copied, there was no heap allocation involved. There were no references involved in any of this. So, there's no reference counting overhead for our point struct. What about a more complicated struct, though? Here we have a label struct which contains text which is of type String and font of type UIFont. String, as we heard earlier, actually stores its -- the contents of its characters on the heap. So, that needs to be reference counted. And font is a class. And so that also needs to be reference counted. If we look at our memory representation, labels got two references. And when we make a copy of it, we're actually adding two more references, another one to the text storage and another one to the font. The way Swift tracks this -- these heap allocations is by adding calls to retain and release.

그렇다면 구조체는 어떤가요? 구조체와 관련된 참조 계산이 있나요? 포인트 구조체를 생성할 때는 힙 할당이 필요하지 않았습니다. 복사할 때도 힙 할당은 없었습니다. 이 모든 과정에는 참조가 포함되지 않았습니다. 따라서 포인트 구조체에는 참조 계산 오버헤드가 없습니다. 하지만 더 복잡한 구조체는 어떨까요? 여기에는 String 타입의 텍스트와 UIFont 타입의 글꼴이 포함된 레이블 구조체가 있습니다. 앞서 들었듯이 String은 실제로 그 문자의 내용을 힙에 저장합니다. 따라서 참조 카운트가 필요합니다. 그리고 폰트는 클래스입니다. 따라서 이것도 참조 카운트해야 합니다. 메모리 표현을 보면 레이블은 두 개의 참조를 가지고 있습니다. 그리고 복사본을 만들 때 실제로는 텍스트 저장소와 글꼴에 대한 참조를 하나 더 추가하는 것입니다. Swift가 이러한 힙 할당을 추적하는 방법은 리텐션과 릴리스에 호출을 추가하는 것입니다.

So, here we see the label is actually going to be incurring twice the reference counting overhead that a class would have. All right. So, in summary, because classes are allocated on the heap, Swift has to manage the lifetime of that heap allocation. It does so with reference counting. This is nontrivial because reference counting operations are relatively frequently and because of the atomicity of the reference counting.

따라서 여기서는 레이블이 실제로 클래스보다 두 배의 참조 계산 오버헤드를 발생시키는 것을 볼 수 있습니다. 알겠습니다. 요약하자면, 클래스는 힙에 할당되기 때문에 Swift는 힙 할당의 수명을 관리해야 합니다. 이를 위해 참조 카운팅을 사용합니다. 참조 계수 연산이 상대적으로 빈번하고 참조 계수의 원자성 때문에 이것은 중요하지 않습니다.

This is just one more resent to use structs.

이것은 구조체를 사용해야 하는 또 하나의 이유일 뿐입니다.

But if structs contain references, they're going to be paying reference counting overhead as well. In fact, structs are going to be paying reference counting overhead proportional to the number of references that they contain. So, if they have more than one reference, they're going to retain more reference counting overhead than a class. Let's see how we chain apply this to another example coming from my theoretical messaging application. So, my users weren't satisfied with just sending text messages. They also wanted to send attachments like images to each other. And so I have this struct attachment, which is a model object in my application. It's got a fileURL property, which stores the path of my data on disk for this attachment. It has a uuid, which is a unique randomly generated identifier so that we can recognize this attachment on client and server and different client devices. It's got a mimeType, which stores the type of data that this attachment represents like JPG or PNG or GIF. Probably the only nontrivial code in this example is the failable initializer, which checks if the mimeType is one of my supported mimeTypes for this application because I don't support all mimeTypes. And if it's not supported, we're going to abort out of this.

Otherwise, we're going to initialize our fileURL, uuid, and mimeType. So, we noticed a lot of reference counting overhead and if we actually look at our memory representation of this struct, all 3 of our properties are incurring reference counting overhead when you pass them around because there are references to heap allocations underlying each of these structs.

하지만 구조체에 참조가 포함되어 있으면 참조 계산 오버헤드도 발생하게 됩니다. 실제로 구조체는 포함된 참조의 수에 비례하여 참조 계산 오버헤드를 지불하게 됩니다. 따라서 참조가 두 개 이상 있으면 클래스보다 더 많은 참조 계산 오버헤드를 유지하게 됩니다. 이를 이론적인 메시징 애플리케이션의 또 다른 예제에 어떻게 연쇄적으로 적용하는지 살펴봅시다. 사용자들은 단순히 문자 메시지를 보내는 것만으로는 만족하지 않았습니다. 사용자들은 이미지와 같은 첨부 파일도 서로 주고받기를 원했습니다. 그래서 제 애플리케이션에 모델 객체인 구조체 첨부 파일이 있습니다. 이 구조체에는 이 첨부 파일의 데이터 경로를 디스크에 저장하는 fileURL 속성이 있습니다. 여기에는 클라이언트와 서버 및 다른 클라이언트 장치에서 이 첨부 파일을 인식할 수 있도록 무작위로 생성된 고유 식별자인 uuid가 있습니다. 이 첨부파일이 나타내는 데이터 유형(예: JPG, PNG, GIF)을 저장하는 mimeType이 있습니다. 이 예제에서 유일하게 중요하지 않은 코드는 아마도 실패할 수 있는 이니셜라이저일 텐데, 이 이니셜라이저는 모든 mimeType을 지원하지 않기 때문에 해당 mimeType이 이 애플리케이션에서 지원되는 mimeType 중 하나인지 확인합니다. 지원되지 않는다면 여기서 중단할 것입니다. 그렇지 않으면 fileURL, uuid 및 mimeType을 초기화할 것입니다. 따라서 참조 카운팅 오버헤드가 많이 발생한다는 것을 알 수 있었고 실제로 이 구조체의 메모리 표현을 살펴보면 각 구조체의 기반이 되는 힙 할당에 대한 참조가 있기 때문에 세 가지 프로퍼티를 전달할 때 참조 카운팅 오버헤드가 발생하고 있습니다.

We can do better.

First, just like we saw before, uuid is a really well defined concept. It's a 128 bit randomly generated identifier. And we don't want to just allow you to put anything in the uuid field. And, as a String, you really can. Well, Foundation this year added a new value type and so -- for uuid, which is really great because it stores those 128 bits in line directly in the struct. And so let's use that. What this is going to do is it's going to eliminate any of the reference counting overhead we're paying for that uuid field, the one that was a String. And we've got much more tight safety because I can't just put anything in here. I can only put a uuid. That's fantastic. Let's take a look at mimeType and let's look at how I've implemented this isMimeType check. I'm actually only supporting a closed set of mimeTypes today, JPG, PNG, GIF.

우리는 더 잘할 수 있습니다.

첫째, 앞서 살펴본 것처럼 uuid는 정말 잘 정의된 개념입니다. 128비트 무작위로 생성된 식별자입니다. 그리고 uuid 필드에 아무거나 입력할 수 있도록 허용하고 싶지 않습니다. 그리고 문자열로 입력할 수 있습니다. 올해 Foundation에서 새로운 값 유형이 추가되었는데, uuid의 경우 128비트를 구조체에 직접 일렬로 저장하기 때문에 정말 좋습니다. 이제 이를 사용해보겠습니다. 이렇게 하면 문자열이었던 uuid 필드에 대해 지불하던 참조 계산 오버헤드를 제거할 수 있습니다. 그리고 여기에 아무거나 넣을 수 없기 때문에 훨씬 더 엄격한 안전성을 확보할 수 있습니다. uuid만 넣을 수 있습니다. 정말 멋지네요. 이제 mimeType을 살펴보고 이 isMimeType 검사를 어떻게 구현했는지 살펴봅시다. 현재 저는 JPG, PNG, GIF라는 폐쇄형 mimeType 세트만 지원하고 있습니다.

And, you know, Swift has a great abstraction mechanism for representing a fixed set of things. And that's an enumeration. So, I'm going to take that switch statement, put it inside a failable initializer and map those mimeTypes to an appropriate -- to the appropriate case in my enum. So, now I've got more type safety with this mimeType enum and I've also got more performance because I don't need to be storing these different cases indirectly on the heap. Swift actually has a really compact and convenient way for writing this exact code, which is using enum that's backed by a raw String value. And so this is effectively the exact same code except it's even more powerful, has the same performance characteristics, but it's way more convenient to write. So, if we looked at our attachment struct now, it's way more type safe. We've got a strongly typed uuid and mimeType field and we're not paying nearly as much reference counting overhead because uuid and mimeType don't need to be reference counted or heap allocated.

그리고 아시다시피 Swift에는 고정된 집합을 표현하는 훌륭한 추상화 메커니즘이 있습니다. 바로 열거형입니다. 따라서 스위치 문을 가져와서 실패할 수 있는 이니셜라이저 안에 넣고 해당 mimeTypes를 내 열거형의 적절한 케이스에 매핑할 것입니다. 이제 이 mimeType 열거형을 사용하면 유형 안전성이 향상되고 다양한 케이스를 힙에 간접적으로 저장할 필요가 없으므로 성능도 향상됩니다. 실제로 Swift에는 원시 문자열 값으로 뒷받침되는 열거형을 사용하는 매우 간결하고 편리한 코드 작성 방법이 있습니다. 따라서 이것은 훨씬 더 강력하고 성능 특성이 동일하지만 작성하기가 훨씬 더 편리하다는 점을 제외하면 사실상 완전히 동일한 코드입니다. 이제 첨부 파일 구조체를 살펴보면 훨씬 더 유형 안전합니다. 강력하게 타입이 지정된 uuid와 mimeType 필드가 있고, uuid와 mimeType은 참조 카운트나 힙 할당이 필요하지 않으므로 참조 카운트 오버헤드가 거의 발생하지 않습니다.

Struct 안에서 Heap을 사용하는 참조 형식의 타입을 많이 쓴다면
오버헤드 발생을 최대한 줄일 수 있는 방법을 사용
enum type <-> String
uuid, URL등 swift가 지원하는 type 형식


18:20