본문으로 건너뛰기

Jank Busters Part Two: Orinoco

· 약 5분
the jank busters: Ulan Degenbaev, Michael Lippautz, and Hannes Payer

이전 블로그 게시글에서 우리는 가비지 컬렉션이 부드러운 브라우징 경험을 방해하며 발생하는 잉크 문제를 소개했습니다. 이 블로그 게시글에서는 새로운 V8 가비지 컬렉터인 _Orinoco_를 구축하기 위한 세 가지 최적화를 소개합니다. Orinoco는 대부분 병렬적이고 동시적 가비지 컬렉터를 엄격한 세대 경계를 넘어 구현함으로써 가비지 컬렉션 잉크와 메모리 소비를 줄이는 동시에 높은 처리량을 제공할 수 있다는 아이디어를 바탕에 두고 있습니다. Orinoco를 별도의 가비지 컬렉터로서의 플래그 뒤에 구현하는 대신, 즉각적으로 사용자에게 도움을 줄 수 있도록 Orinoco의 기능들을 V8의 최상위 트리에 점진적으로 배포하기로 결정하였습니다. 이번 글에서 논의된 세 가지 기능은 병렬 압축, 병렬 기억 집합 처리, 및 블랙 할당입니다.

V8은 객체가 젊은 세대 내에서 이동할 수 있고, 젊은 세대에서 오래된 세대로 이동하며, 오래된 세대 내에서도 이동할 수 있는 세대 기반 가비지 컬렉터를 구현합니다. 객체가 이동하는 것은 객체의 기본 메모리를 새 위치로 복사해야 하며, 해당 객체에 대한 포인터도 업데이트해야 하므로 비용이 많이 듭니다. 그림 1에서는 Orinoco 이전의 단계와 실행 방식을 보여줍니다. 기본적으로, 객체는 먼저 이동한 다음 이러한 객체 사이의 포인터는 차례로 업데이트되며 이 모든 것이 순차적으로 이루어져 눈에 띄는 잉크를 초래하게 됩니다.

그림 1: 객체를 순차적으로 이동하고 포인터를 업데이트하는 방식

V8은 고정 크기 청크로 나뉜 힙 메모리를 '페이지'라고 부르며 이는 젊은 세대 공간 또는 오래된 세대 공간으로 지정됩니다. 객체는 처음에는 젊은 세대에 할당됩니다. 가비지 컬렉션 시 살아있는 객체는 젊은 세대 내에서 한 번 이동됩니다. 또 다른 가비지 컬렉션을 견딘 객체는 오래된 세대로 승격됩니다. 이를 젊은 세대 대피라고 집합적으로 부르며, 우리는 페이지를 기반으로 메모리 복사를 병렬화합니다. 젊은 세대 내에서는 객체 이동이 항상 새로운 페이지에서 메모리를 할당하고 오래된 페이지를 해제하여 압축된 메모리 레이아웃을 남깁니다. 오래된 세대에서는 이 과정이 약간 다른 방식으로 이루어지는데, 이는 죽은 메모리가 사용할 수 없는 구멍(또는 단편화)을 남기기 때문입니다. 이러한 일부 구멍은 자유 목록을 통해 재사용할 수 있지만, 나머지는 성능을 위해 살아있는 객체를 더 잘 채운(잠재적으로 새로운) 페이지로 이동해야 하는 압축을 필요로 합니다. 젊은 세대와 마찬가지로 이 과정은 페이지 수준에서 병렬화됩니다.

젊은 세대 대피와 오래된 세대 압축 사이에 종속성이 없으므로, Orinoco는 이제 이러한 단계를 병렬로 수행합니다. 그림 2에서 보여지듯이 이러한 개선의 결과는 ~7ms에서 평균적으로 2ms 이하로 압축 시간이 75% 감소됩니다.

그림 2: 객체를 병렬로 이동하고 포인터를 업데이트하는 방식

Orinoco에서 도입된 두 번째 최적화는 가비지 컬렉션이 포인터를 추적하는 방식을 개선합니다. 힙 상에서 객체가 이동할 때, 가비지 컬렉터는 이동한 객체의 이전 위치를 포함하고 있는 모든 포인터를 찾아 새 위치로 업데이트해야 합니다. 힙 전체를 반복하여 포인터를 찾는 것은 매우 느릴 수 있으므로, V8은 힙에 있는 모든 중요한 포인터를 추적하기 위해 _기억 집합_이라는 데이터 구조를 사용합니다. 특정 포인터는 가비지 컬렉션 동안 이동할 가능성이 있는 객체를 가리키기 때문에 중요합니다. 예를 들어, 모든 오래된 세대에서 젊은 세대로의 포인터는 중요합니다. 왜냐하면 젊은 세대 객체는 매번 가비지 컬렉션에서 이동하기 때문입니다. 매우 단편화된 페이지의 객체로의 포인터도 중요합니다. 왜냐하면 이러한 객체는 압축 중에 다른 페이지로 이동할 것이기 때문입니다.

이전에 V8은 기억된 집합을 포인터 주소 배열 또는 _스토어 버퍼_로 구현했습니다. 젊은 세대와 분리된 오래된 세대의 각 페이지마다 하나의 스토어 버퍼가 있었습니다. 페이지의 스토어 버퍼는 그림 3에 나온 것처럼 모든 입력 포인터의 주소를 포함합니다. JavaScript 코드의 쓰기 작업을 보호하는 _쓰기 장벽_에서 항목이 스토어 버퍼에 추가됩니다. 이로 인해 스토어 버퍼에 동일한 포인터가 여러 번 포함되지 않더라도 중복 항목이 생성될 수 있으며, 서로 다른 두 스토어 버퍼에 동일한 포인터가 포함될 수 있습니다. 중복 항목은 두 스레드가 동일한 포인터를 업데이트하려고 시도해 데이터 경쟁이 발생하기 때문에 포인터 업데이트 단계를 병렬화하는 데 어려움을 줍니다.

그림 3: 이전 기억된 집합

Orinoco는 기억된 집합을 재구성하여 병렬화를 단순화하고 스레드가 업데이트할 포인터의 서로 다른 집합을 받도록 보장함으로써 이러한 복잡성을 제거합니다. 인바운드 흥미로운 포인터를 배열에 저장하는 대신 이제 각 페이지는 그림 4와 같이 해당 페이지에서 시작된 흥미로운 포인터의 오프셋을 비트맵 버킷에 저장합니다. 각 버킷은 비어 있거나 고정 길이 비트맵을 가리킬 수 있습니다. 비트맵의 비트는 페이지 내의 포인터 오프셋에 해당합니다. 비트가 설정되면 포인터가 흥미로우며 기억된 집합에 포함됩니다. 이러한 데이터 구조를 통해 페이지 기반으로 포인터 업데이트를 병렬화할 수 있습니다. 중복 항목이 없고 포인터 표현이 밀집되어 있으므로 기억된 집합 오버플로를 처리하는 복잡한 코드를 제거할 수 있었습니다. 장시간 실행된 Gmail 벤치마크에서 이 변경으로 인해 압축 가비지 수집의 최대 일시 중지 시간이 42ms에서 23ms로 45% 줄어들었습니다.

그림 4: 새로운 기억된 집합

Orinoco가 도입하는 세 번째 최적화는 가비지 컬렉터의 마킹 단계의 개선인 _블랙 할당_입니다. 블랙 할당(V8 5.1에 도입됨)은 오래된 세대에 할당된 모든 객체(예: 사전 노화 할당 또는 가비지 컬렉터에 의해 승격된 객체)를 즉시 검은색으로 마킹하여 "활성"으로 표시하는 가비지 수집 기술입니다. 블랙 할당의 직관은 오래된 세대에 할당된 객체가 대부분 오래 살아남을 것이라는 것입니다. 따라서 오래된 세대에 최근에 할당된 객체는 적어도 다음 오래된 세대 가비지 수집을 살아남아야 하며, 그렇지 않으면 잘못 승격된 것입니다. 새로 할당된 객체를 검은색으로 색칠한 후 가비지 컬렉터는 이를 방문하지 않습니다. 모든 객체가 기본적으로 검은색인 검은색 페이지에 객체를 할당함으로써 검은색 객체의 색칠 속도를 높입니다. 블랙 페이지의 또 다른 이점은 그 위에 할당된 모든 객체가 (정의상) 활성 상태이므로 스윕할 필요가 없다는 것입니다. 블랙 할당은 새 할당으로 인해 마킹 작업이 증가하지 않기 때문에 점진적 마킹 진행을 가속화합니다. 블랙 할당의 영향은 Octane Splay 벤치마크에서 명확히 보이며, 마킹 진행 속도가 빨라지고 가비지 수집 작업이 전반적으로 감소하여 약 30%의 처리량과 대기 시간 점수가 향상되고 약 20%의 메모리를 적게 사용합니다.

우리는 곧 더 많은 Orinoco 기능을 출시할 예정입니다. 계속 지켜봐 주세요. 아직도 개선 작업 중입니다!