게으른 역직렬화
요약: 최근 V8 v6.4에서 기본적으로 게으른 역직렬화를 활성화하여 V8의 브라우저 탭당 메모리 소비량을 평균적으로 500 KB 이상 감소시켰습니다. 자세히 알아보세요!
V8 스냅샷 소개
하지만 먼저, V8이 힙 스냅샷을 사용하여 새 Isolate(대략 크롬의 브라우저 탭과 일치)를 생성하는 속도를 어떻게 높이는지에 대해 살펴보겠습니다. 제 동료 Yang Guo는 사용자 정의 시작 스냅샷 기사에서 이에 대해 잘 설명했습니다:
JavaScript 사양에는 수학 함수부터 완전한 정규 표현식 엔진에 이르기까지 많은 기본 제공 기능이 포함되어 있습니다. 새로 생성된 모든 V8 컨텍스트는 처음부터 이러한 기능들을 사용할 수 있습니다. 이를 위해서는 전역 객체(예: 브라우저의
window
객체)와 모든 기본 제공 기능이 설정되고 컨텍스트가 생성될 때 V8의 힙에 초기화되어야 합니다. 이를 처음부터 실행하는 데는 꽤 시간이 걸립니다.다행히도 V8은 일을 빠르게 하기 위해 바로가기 방법을 사용합니다: 바쁜 저녁 식사를 위해 얼어붙은 피자를 데우는 것처럼, 우리는 미리 준비된 스냅샷을 힙으로 직접 역직렬화하여 초기화된 컨텍스트를 얻습니다. 일반 데스크탑 컴퓨터에서는 컨텍스트 생성 시간을 40 ms에서 2 ms 미만으로 줄일 수 있습니다. 평균적인 모바일 전화에서는 270 ms에서 10 ms로 차이를 만들 수 있습니다.
요약하자면: 스냅샷은 시작 성능에 필수적이며, 이는 각 Isolate의 초기 V8 힙 상태를 생성하기 위해 역직렬화됩니다. 따라서 스냅샷 크기는 V8 힙의 최소 크기를 결정하며, 더 큰 스냅샷은 각 Isolate의 메모리 소비를 직접적으로 증가시킵니다.
스냅샷에는 새 Isolate를 완전히 초기화하는 데 필요한 모든 것이 포함됩니다. 여기에 언어 상수(undefined
값 등), 인터프리터에서 사용되는 내부 바이트코드 핸들러, 기본 제공 객체(String
등), 기본 제공 객체에 설치된 함수(String.prototype.replace
등)와 실행 가능한 Code
객체 등이 포함됩니다.
지난 2년 동안 스냅샷 크기는 약 600 KB(2016 초반)에서 현재 1500 KB 이상으로 거의 세 배 증가했습니다. 이러한 증가는 대부분 직렬화된 Code
객체에서 비롯되며, 이는 수량이 증가한 것(예: JavaScript 언어 사양이 발전하고 커지며 최근 추가된 기능들)과 크기가 증가한 것(new CodeStubAssembler 파이프라인의 빌트인이 컴팩트한 바이트코드나 최소화된 JS 형식 대신 네이티브 코드로 생성됨)에 기인합니다.
이것은 나쁜 소식입니다, 왜냐하면 우리는 메모리 소비를 가능한 한 낮게 유지하고 싶기 때문입니다.
게으른 역직렬화
주요 문제 중 하나는 이전에는 스냅샷의 전체 내용이 각 Isolate에 복사되었던 점입니다. 이렇게 하는 것은 기본 제공 함수의 경우 특히 낭비가 심했습니다. 이러한 함수는 모두 무조건 로드되었으나 실제로 사용되지 않을 수도 있었습니다.
여기에서 게으른 역직렬화가 등장합니다. 개념은 매우 간단합니다: 기본 제공 함수가 호출되기 직전에만 역직렬화한다면 어떨까요?
가장 인기 있는 몇몇 웹사이트를 빠르게 조사한 결과, 이 접근법이 매우 매력적임을 보여주었습니다: 평균적으로 전체 기본 제공 함수 중 30%만 사용되었으며, 일부 사이트는 단 16%만 사용됩니다. 이러한 수치는 웹의 일반적인 메모리 절약 가능성의 (모호한) 하한으로 간주될 수 있기 때문에 상당히 유망해 보였습니다.
이 방향으로 작업을 시작하면서 게으른 역직렬화가 V8의 아키텍처와 매우 잘 통합되었고, 시작하고 실행하는 데 필요한 설계 변경이 거의 없고 대부분 침습적이지 않다는 사실이 밝혀졌습니다:
- 스냅샷 내에서 잘 알려진 위치. 게으른 역직렬화 이전에는 직렬화된 스냅샷 내 객체의 순서가 중요하지 않았습니다. 왜냐하면 전체 힙을 한 번에 역직렬화할 뿐이었기 때문입니다. 게으른 역직렬화는 특정 기본 제공 함수를 단독으로 역직렬화할 수 있어야 하며, 따라서 해당 함수가 스냅샷 내에서 어디에 위치하는지를 알고 있어야 합니다.
- 단일 객체의 역직렬화. V8의 스냅샷은 원래 전체 힙 역직렬화를 위해 설계되었지만, 단일 객체 역직렬화 지원을 추가하는 데에는 몇 가지 특이사항을 처리해야 했습니다. 예를 들어 연속되지 않은 스냅샷 레이아웃(하나의 객체에 대한 직렬화된 데이터가 다른 객체의 데이터와 섞일 수 있음)과 소위 역참조(현재 실행 중에 이전에 역직렬화된 객체를 직접 참조할 수 있음)가 포함되었습니다.
- 지연 역직렬화 메커니즘 자체. 런타임 시, 지연 역직렬화 핸들러는 a) 어떤 코드 객체를 역직렬화할지 결정하고, b) 실제 역직렬화를 수행하며, c) 직렬화된 코드 객체를 관련 함수에 연결해야 합니다.
첫 번째와 두 번째 문제에 대한 우리의 해결책은 스냅샷에 새로운 전용 빌트인 영역을 추가하는 것이었습니다. 이 영역에는 직렬화된 코드 객체만 포함될 수 있습니다. 직렬화는 잘 정의된 순서로 발생하며 각 Code
객체의 시작 오프셋은 빌트인 스냅샷 영역 내의 전용 섹션에 보관됩니다. 역참조와 객체 데이터가 섞이는 것은 허용되지 않습니다.
지연 빌트인 역직렬화는 적절히 이름이 붙여진 DeserializeLazy
빌트인에 의해 처리됩니다. 이는 역직렬화 시점에 모든 지연 빌트인 함수에 설치됩니다. 런타임에 호출되면 관련된 Code
객체를 역직렬화하고 최종적으로 이를 JSFunction
(함수 객체를 나타냄)과 SharedFunctionInfo
(동일한 함수 리터럴에서 생성된 함수들 간에 공유됨)에 설치합니다. 각 빌트인 함수는 최대 한 번만 역직렬화됩니다.
빌트인 함수 외에도 바이트코드 핸들러의 지연 역직렬화를 구현했습니다. 바이트코드 핸들러는 V8의 Ignition 인터프리터 내에서 각 바이트코드를 실행하기 위한 로직을 포함하는 코드 객체입니다. 빌트인과 달리, 이들은 JSFunction
이나 SharedFunctionInfo
가 연관되어 있지 않습니다. 대신, 그들의 코드 객체는 인터프리터가 다음 바이트코드 핸들러로 디스패치할 때 인덱싱하는 디스패치 테이블에 직접 저장됩니다. 지연 역직렬화는 빌트인과 유사하며, DeserializeLazy
핸들러가 바이트코드 배열을 검사하여 어떤 핸들러를 역직렬화할지 결정하고, 코드 객체를 역직렬화하며, 최종적으로 역직렬화된 핸들러를 디스패치 테이블에 저장합니다. 이 경우에도 각 핸들러는 최대 한 번만 역직렬화됩니다.
결과
Android 장치에서 Chrome 65를 사용하여 상위 1000개의 인기 웹사이트를 로드하며 지연 역직렬화를 활성화하거나 비활성화하여 메모리 절감을 평가했습니다.
평균적으로 V8의 힙 크기는 540 KB 감소했으며, 테스트된 사이트 중 25%는 620 KB 이상, 50%는 540 KB 이상, 75%는 420 KB 이상을 절약했습니다.
런타임 성능(표준 JS 벤치마크인 Speedometer 및 다양한 인기 웹사이트를 기준으로 측정)은 지연 역직렬화로 인해 영향을 받지 않았습니다.
다음 단계
지연 역직렬화는 각 Isolate가 실제로 사용되는 빌트인 코드 객체만 로드하도록 보장합니다. 이는 이미 상당한 개선이지만, 우리는 한 단계 더 나아가 각 Isolate의 (빌트인과 관련된) 비용을 효과적으로 0으로 줄일 수 있을 것으로 믿습니다.
올해 말에 이와 관련된 업데이트를 가져올 수 있기를 바랍니다. 기대해주세요!