메서드 디스패치와 성능

Swift를 배우다 보면 접하게 되는 메서드 디스패치에 대해 알아보도록 하겠습니다.

단순히 Struct는 Static Dispatch, Class는 Dynamic Dispatch라고 생각하게 되지만, Method Dispatch의 자세한 메커니즘이 궁금해져서 WWDC를 시청해 보았습니다.
이를 정리하지 않으면 안되겠다는 생각이 들어 글을 쓰게 되었습니다. 🙂


참조

[WWDC: Understanding Swift Performance]


Stack과 Heap

Method Dispatch를 제대로 사용하려면 Stack과 Heap에 대한 이해가 필요한데, 이는 성능과 깊게 연관되어 있습니다.
Stack과 Heap의 중요한 차이점은 무엇일까요? 바로 Stack에 값을 할당하는 작업보다 Heap에 값을 할당하는 작업이 더 비용이 크다는 점입니다.

Stack의 경우 스택 포인터를 움직여가며 한쪽에서만 값을 추가하고 제거하는데, 이 비용은 정수를 할당하는 비용과 동일합니다.

하지만 Heap의 경우는 어떨까요? 동적으로 메모리를 할당하고 해제하기 위해서 Heap 내부를 검색하여 적절한 크기의 사용되지 않은 블록을 찾는 과정이 필요합니다. 또한 Heap에 값을 할당할 때 발생하는 주요 비용 중 하나는, 여러 스레드가 동시에 메모리를 할당할 수 있어 무결성을 보호하기 위한 잠금(동기화 메커니즘)이 필요하다는 것입니다.

스택은 할당이 빠르고, 힙은 메모리 관리 오버헤드 및 동기화 메커니즘으로 비용이 크다는 것을 염두하고 다음으로 넘어가 보겠습니다.


정적 디스패치 (Static Dispatch)

컴파일 시점에 실행할 구현을 결정할 수 있는 경우를 말합니다. 이것이 왜 중요할까요?

컴파일 시점에 어떤 구현이 실행될지 미리 알고 있으면, 컴파일러는 실행될 구현에 대한 정보를 기반으로 최적화(ex. 인라이닝)가 가능합니다.

그렇다면 인라이닝이라는 것은 무엇일까요? 우리는 함수를 만나게 되면 해당 함수의 구현으로 점프하게 됩니다. 이때 컴파일러의 인라이닝 최적화가 진행되면 함수 호출을 구현으로 대체하게 됩니다. 즉 호출 스택 설정과 해제의 오버헤드가 불필요해지는 것이죠.

이 효과는 연쇄적인 호출에서 더 강력해지는데, 컴파일러는 전체 연쇄를 관통하여 정적 디스패치들을 하나의 구현으로 통합하는 것이 가능합니다. (즉, 호출 스택 오버헤드 없이 처리할 수 있습니다.)


동적 디스패치 (Dynamic Dispatch)

동적 디스패치는 컴파일 시점에 구현을 결정할 수 없기 때문에, 런타임에 실제로 구현을 조회한 후 실행하는 방식입니다. 이러한 동적 디스패치의 경우 컴파일러가 더 높은 수준에서 추론하는 것을 불가능하게 합니다. 즉 컴파일러에서 제공하는 최적화 과정을 방해하게 됩니다.

대신 동적 디스패치는 다형성을 지원하여, 추상 클래스에 대한 다양한 구현을 가능하게 합니다. 각 클래스는 정적 메모리에 존재하는 타입 정보로 향하는 포인터 필드를 가지고 있으며, 이 메모리의 가상 메서드 테이블(V-Table)을 조회하여 올바른 구현을 가리키는 포인터를 찾게 됩니다.


final

다형성이 필요 없는 경우, final 키워드를 붙이게 되면 서브클래싱되지 않을 것이라는 의미를 담게 됩니다. 따라서 컴파일러는 이를 감지하고 정적 디스패치를 진행할 수 있습니다.


여기까지 보면 구조체를 사용하는 것이 좋아보이는데, 다형성을 활용하기 위해서는 동적 기능에 대한 비용을 지불할 수 밖에 없는지 의문이 생기게 됩니다. 이 의문을 해결하기 위해 Protocol의 메서드 디스패치에 대해서 알아보겠습니다.


Existential Container

프로토콜을 사용할 때 등장하는 저장소 레이아웃 개념입니다.

이 저장소 개념을 통해 실제로는 서로 다른 프로토콜 타입들을 어떻게 묶어주는지에 대한 의문을 해결할 수 있습니다.

컨테이너 안에는 valueBuffer를 위한 공간과, Value Witness Table, Protocol Witness Table에 대한 참조가 저장되어 있습니다.

valueBuffer

만약 프로토콜 타입 값이 valueBuffer에 충분히 들어간다면 이를 인라인으로 저장하지만, 더 많은 공간이 필요한 경우에는 힙에 메모리를 할당하여 포인터를 저장하게 됩니다. (즉, 경우에 따라 힙 할당이 발생할 수 있습니다.)

Value Witness Table

이는 각 프로토콜 타입의 값의 생애 주기를 관리하며, 각 타입마다 가지고 있는 테이블입니다.

예를 들어 프로토콜 타입의 로컬 변수를 생성하며 발생하는 메모리 할당, 값 복사 그리고 생애 주기가 종료되었을 때 발생하는 참조 해제 및 메모리 해제 작업을 처리하는 메커니즘이 들어있다고 할 수 있습니다.

Protocol Witness Table

각 프로토콜 타입마다 Witness Table을 가지며, 이 테이블에 실제 구현이 존재합니다.


따라서 프로토콜 타입의 경우에는 Witness Table을 통해 동적 다형성을 가능하게 한다는 것을 알 수 있습니다.
그렇다면 제네릭 타입에서는 메서드 디스패치가 어떤 방식으로 진행되는 걸까요?


Parametric Polymorphism

프로토콜 타입과 비교하여, 제네릭에서 지원하는 정적 형태의 다형성입니다. 즉, 호출하는 컨텍스트에서 하나의 타입을 사용할 수 있습니다. 이 말은, 호출 축에서 주어진 제네릭 타입이 매개변수를 따라 호출 체인 아래로 대체되는 것을 의미합니다.

결국 제네릭의 경우 호출 컨텍스트마다 하나의 타입이 존재하기 때문에, 존재적 컨테이너를 사용하지 않습니다. 대신, 스택에 valueBuffer만 할당하고 호출 지점에서 사용된 타입 자체의 Value Witness Table 및 Protocol Witness Table을 추가로 인수로 전달하게 됩니다.


이러한 정적 다형성은 프로토콜 타입에 비해 제네릭 특화(Specialization of Generics)라는 컴파일 최적화를 적용시킬 수 있습니다.

Swift에서는 제네릭 호출 지점에서 사용하는 타입마다 특화된 버전의 함수를 생성합니다. 이것은 제네릭을 사용했을 때 호출 지점에서 하나의 타입이 존재한다는 것을 알고 있기 때문에 가능한 것입니다. 이렇게 하면 오히려 코드 크기가 증가할 것이라고 생각할 수 있지만, 컴파일러가 메서드를 인라인화하며 함수 호출을 줄여가고, 더 이상 참조되지 않는 메서드를 제거하기 때문에 오히려 최적화를 통해 코드 크기가 감소할 수도 있다고 합니다.


즉 결론적으로는, 제네릭 코드가 컴파일러에 의해 특화되었을 때는 제네릭 타입이 실제 타입으로 대체되므로 별도의 디스패치 과정이 필요하지 않은 것으로 보이고, 특화되지 않았을 때는 Protocol Witness Table을 전달받아 메서드를 디스패치한다고 볼 수 있을 것 같습니다.