Programming/V8 Engine

[ Node.js #03 V8 아키텍처 ] Garbage Collection

DataArtist 2023. 2. 19. 18:38

지난 포스팅에서는 V8 엔진의 소스코드 컴파일 및 실행하는 과정을 작성하였었습니다.

V8엔진에서 Code Caching이 핵심 기술이라고 기술하였는데

어떠한 과정을 거쳐서 진행되는지가 제일 중요합니다.

 

그렇다면 V8 엔진이 어떻게 작동하는지 조금 더 자세하게 알아보겠습니다.

 

V8의 역할

   1) JavaScript 소스 코드를 컴파일 및 실행

   2) 힙 메모리 객체에 대한 메모리 할당

   3) Garbage Collection

   4) 콜스택 핸들링

 

위의 역할중에 GC가 V8의 핵심 기능이라고 볼 수 있습니다.

GC의 역할은 주기적으로 작업이 완료된 객체 정보 수집하여 재사용할 수 있는 메모리로 전환하는 역할을 합니다.

 

가장 초기 아키텍처(2015년 10월 30일)부터 살펴보면

V8 GC의 대부분이 메인 렌더링 스레드에서 수행되지만 기본 스레드에서 수행해야 하는 작업을 줄이기 위해

사용하지 않는 페이지의 동시 매핑 해제 방식으로 동작하도록 설계하였습니다.

그림 1 [Some GC operations performed on the concurrent garbage collection threads ]

 

그 이후로도 파이프라인의 패치들이 일어났고..

 

아키텍처의 변화

2015년 공식 홈페이지에서 소개되었던 아키텍처가 V8 5.0 엔진으로 고도화되면서 큰 변화가 생겼습니다.

그 코드 네임을 Orinoco라고 정의했고 핵심적인 부분은 메모리 영역을 두가지 세그먼트로 분리했다는 점입니다.

New space : Young generation

Old space : Old generation

 

"그림 2"에서 "그림 3" 방식으로 포인터 업데이트 방식 구조가 병렬 형태로 변경되었는데

아래의 구조를 보면 오브젝트가 힙에서 이동될 때 개체간의 포인터를 모두 순차적으로 업데이트 해주어야 했습니다.

그림 2 [Sequential moving of objects and updating pointers]

그러다보니 버벅거리는 문제들이 많이 발생했습니다.

그림 3 [Parallel moving of objects and updating pointers]

V8의 Orinoco에서는 3가지 최적화를 도입했습니다.

 

1. heap memory page 분리

1차적으로 heap memory라고 불리는 고정 사이즈 덩어리로 분할하여

Young generation 또는 Old generation 공간에 할당됩니다.

[편하게 부르기 위해 Y : Young, O : Old 라고 표현하겠습니다.]

2차로는 객체는 초기에 Y세대에 할당되며 GC시에 Y세대 내에서 한 번 이동되고

다른 GC에서 살아남은 개체들은 O세대로 승격됩니다.

 

위의 1, 2차를 Y세대 집합이라는 페이지를 기반으로 메모리 복사를 병렬화하고

Y세대 내에서의 개체 이동은 항상 새로운 페이지에 메모리를 할당하며(및 이전 페이지 해제) 메모리 레이아웃을 압축합니다.

 

그리고 O세대에서는 죽은 메모리 찌꺼기를 남기기 때문에 이 프로세스가 조금 다른 방식으로 발생합니다.

찌꺼기 일부는 일부 재사용이 가능하지만 나머지는 새로운 Y세대 집합 페이지로 이동하기 위해 압축을 진행합니다.

 

위와 같이 Young generation, Old generation 압축 사이에 종속성이 없기 때문에 병렬로 처리 수행하며

이러한 개선 결과 압축 시간이 ~7ms에서 2ms 미만으로 75% 감소했습니다.

공식문서 "Jank Busters Part Two: Orinoco" 참조

 

2. GC(garbage collection) pointers 추척 방식 개선

개체가 힙에서 위치를 이동하게 되면 GC는 이동된 개체의 이전 위치를 포함하는

모든 포인터(pointer)들을 찾아 새로운 위치로 업데이트 해야합니다.

포인터들을 찾기 위해 힙(heap)를 반복하는 것은 성능을 매우 낮추기 때문에

V8은 메모리 집합이라는 데이터 구조를 사용하여 힙에 있는 모든 포인터들를 추척합니다.

 

기존에는 아래의 사진과 같이 pointer addresses들을 arrays 또는 store buffers들로 구현했었습니다.

그림 4 Old remembered set

위와 같이 페이지(page)안에는 Y세대를 위한 하나의 Store Buffer와

O세대의 찌꺼기가 있는 Store Buffer들이 있습니다.

 

Store Buffer의 저장 페이지에는 그림 4와 같이 모든 포인터들의 주소가 포함되어 있는데

이와 같이 아키텍처를 설계했을 때 store buffer가 포인터를 여러 번 포함할 수 있고

두개의 서로 다른 store buffer가 동일한 포인터를 포함할 수 있기 때문에 중복될 수 있는 이슈가 있었습니다.

이 이슈는 동일한 포인터를 업데이트하려는 두 threads로 인해 발생하는 경합으로

포인터 업데이트 단계에서 병렬화를 어렵게 만드는 상황이었습니다.

 

위와 같은 이슈를 해결하고자 아래 그림 5와 같이 

그림 5 New remembered set

Orinoco는 page 세트들을 재구성하여 병렬화를 단순화하고

thread가 업데이트할 분리된 포인터 세트를 얻도록 함으로서 이러한 복잡성을 제거함으로

GC 최대 일시 중지 시간이 42ms에서 23ms로 45% 감소 했습니다.

 

 

3. GC의 마킹 단계를 개선

마지막으로 3번째는 Black allocation이라는 GC의 마킹 단계를 개선함 입니다.

V8.5.1부터 제공하게 되었고 기존 세대인 Mark-Sweep-Compact는 메모리 사용에 좋지 않아

이를 개선하기 위해 Tri-color Mark-Sweep를 적용하게 되었습니다.

 

내용을 간단하게 설명한다면 활성화/비활성화 2단계에서 3단계로 늘어났다고 이해하면 좋습니다.

    3-1) mark-and-sweep

 

그림6 mark-and-sweep

위와 같이 mark-and-sweep는 수집 주기 동안을 제외하고는 항상 지워집니다.

첫번째 단계는 mark stage로 root set의 tree들을 순회하고 root에서 요청한 객체를 사용중으로 표시하는 단계이며

두번째 단계에서는 sweep stage로 모든 메모리를 처음부터 끝까지 스캔하여 사용 가능한 모든 블록 또는 사용된 블록을 검사합니다

사용중으로 표시되지 않은 표시되지 않은 항목은 매모리에서 해제되며 사용중으로 표시된 개체의 경우 사용중 플래그가 지워지며 다음 주기를 준비합니다.

 

mark-and-sweep의 방법은 수집 중에 전체 시스템을 일시적으로 중단하는 크리트컬한 이슈가 있고

시기를 일반적으로 예측할 수 없게 정지되어 일부 실시간이나 시간이 중요한 응용 프로그램의 경우 사용이 불가능해질 수 있습니다.

또한 전체 작업 메모리를 두 번 검사해야 하므로 잠재적인 Memory paging 시스템에 문제가 발생할 수 있는 이슈를 가지고 있습니다.

 

 

그렇다면 마킹 단계를 개선한 Black allocation은 어떻게 진행되는지 설명드리겠습니다.

 

    3-2) Black allocation

첫번째 단계는 흰색으로 표기되며 개체의 초기 상태를 의미합니다.

초기단계는 메모리를 재활용할 후보인 개체 집합이라고 이해하면 좋습니다.

 

두번째 단계는 회색으로 개체가 root set에서 도달할 수 있고 검사를 받거나 검사중인 상태입니다.

즉, root set에 도달할 수 있지만 "흰색" 개체에 대한 참조를 위해 아직 스캔되지 않은 모든 개체가 포함됩니다.

root set에 도달할 수 있으므로 GC이 불가능하고 스캔 후 블랙에 남게됩니다.

 

세번째 단계는 검정색(블랙)으로 표기되며 개체 검사가 완료된 표시입니다.

 

 

그림7 Tri-color marking

이미지 출처

위의 그림 7번을 통해 알고리즘적 로직을 확인해봅시다.

  1. 회색 세트에서 개체를 선택하고 블랙 세트로 이동합니다.

  2. 회색 세트를 참조하는 각 흰색 개체를 이동합니다.(이렇게 하면 이 개체나 참조하는 개체가 모두 CG될 수 없습니다.)

  3. 회색 세트가 비워질 때까지 마지막 두 단계를 반복합니다.

회색 세트가 비어 있으면 스캔이 완료된 것입니다.

블랙 개체는 root set에서 접근할 수 있는 반면에 흰색 개체는 접근할 수 없고 GC가 가능합니다.

 

즉, root set에서 즉시 도달할 수 없는 모든 개체가 흰색 세트에 추가되고 개체가 흰색에서 회색으로,

회색에서 블랙으로만 이동할 수 있기 때문에 알고리즘은 중요한 불변성을 유지하는 장점을 가지고 있습니다.

특히 블랙 개체는 흰색 개체를 참조하지 않습니다. 이렇게 하면 회색 세트가 비어 있으면 흰색 개체를 해제할 수 있습니다.

 

위에 mark-and-sweep에서는 시스템을 중단해야하는 이슈가 있었는데

Black allocation(Tri-color marking)도입으로 상당한 시간 동안 시스템을 중단하지 않고

즉석에서 수행할 수 있다는 부분이 시스템에서 필요에 따라가 아니라 주기적으로 GC를 진행할 수 있다는 장점이 생겼습니다.

또한 이를 통해 V8 중단이슈를 해결하였고 불변성의 알고리즘을 이용하여 

아케턱처 변화 2번째로 소개했던 GC(garbage collection) pointers 추척 방식 개선에 속도 영향을 주지 않았을까 싶습니다.

 

 

위와 같이 아키텍처 3가지 변화를 연구하면서 개인적인 생각으로는

GC를 통해 프로그래머가 직접 메모리를 관리하지 않는게 맞는가? 라는 의문이 들었습니다.

 

아키텍처와 같이 Garbage Collection를 통해

프로그래머가 직접 메모리 관리할 필요를 없는 장점이 있지만

반대로 생각해 본다면 메모리가 관리되는 모든 부분의 컨트롤을 포기한다는 의미로 보게 되었습니다.

 

관련하여 고민해본 결과 .. 

V8엔진이 GC를 채용한 아키텍처를 단정지어서 좋다, 나쁘다로 구분할 수 없지만

시간이 지나 V8엔진의 성능이 지속적인 고도화가 된다는 전제로 바라볼 때

메모리를 직접 컨트롤하지 않고도 좋은 퀄리티의 결과물을 더 빠른 시간 이내에 만들 수 있으므로

직접적으로 메모리 고민이 필요한 가능한 다른 언어로는 구현의 시간이 오래걸리므로

시간대비 큰 효과를 누리기에는 좋은 아키텍처인 것 같다는 결론을 내렸습니다.

 

결론 : Nodejs의 V8 매력적이다..