더 가벼운 V8
2018년 말, 우리는 V8의 메모리 사용량을 대폭 줄이기 위한 V8 Lite라는 프로젝트를 시작했습니다. 처음에 이 프로젝트는 메모리 사용량 감소가 실행 속도보다 중요한 저메모리 모바일 기기나 임베디드 사용 사례에 특화된 Lite 모드라는 독립적인 형태로 구상되었습니다. 하지만 작업을 진행하면서, 이 Lite 모드를 위해 적용된 많은 메모리 최적화 방법이 일반 V8에서도 사용할 수 있어 V8의 모든 사용자에게 이점을 줄 수 있다는 것을 깨달았습니다.
이번 게시물에서는 우리가 개발한 주요 최적화 방법과 그것이 실제 작업 환경에서 제공한 메모리 절감 효과를 강조합니다.
참고: 기사를 읽는 것보다 발표를 선호하는 경우 아래 영상을 즐기세요! 그렇지 않은 경우 영상을 건너뛰고 계속 읽으세요.
Lite 모드
V8의 메모리 사용량을 최적화하기 위해, 먼저 V8이 메모리를 어떻게 사용하는지 그리고 어떤 객체 유형이 V8 힙 크기에 크게 기여하는지 이해해야 했습니다. 우리는 V8의 메모리 시각화 도구를 사용하여 여러 일반적인 웹 페이지에서 힙 구성을 추적했습니다.
이를 통해 우리는 V8 힙의 상당 부분이 자바스크립트 실행에 필수적이지 않은 객체에 할당된다는 것을 알게 되었습니다. 이러한 객체는 자바스크립트 실행을 최적화하고 예외 상황을 처리하는 데 사용됩니다. 예로는 최적화된 코드, 코드를 최적화하는 방법을 결정하기 위해 사용되는 유형 피드백, C++와 자바스크립트 객체 간의 바인딩에 대한 중복 메타데이터, 스택 추적 기호화와 같은 예외적인 상황에서만 필요한 메타데이터, 페이지 로드 중 몇 번만 실행되는 함수의 바이트코드 등이 있습니다.
그 결과, 이러한 선택적 객체의 할당을 대폭 줄여 자바스크립트 실행 속도와 메모리 절감을 상호 교환하여 Lite 모드를 개발하기 시작했습니다.
Lite 모드의 많은 변경 사항은 기존 V8 설정을 구성하여 구현할 수 있었습니다. 예를 들어 V8의 TurboFan 최적화 컴파일러를 비활성화하는 것입니다. 하지만 다른 경우에는 V8의 대대적인 변경이 필요했습니다.
특히, Lite 모드는 코드를 최적화하지 않기 때문에, 최적화 컴파일러에 필요한 유형 피드백 수집을 피할 수 있었습니다. Ignition 인터프리터에서 코드를 실행할 때, V8는 다양한 연산(예: +
또는 o.foo
)에 전달된 피연산자의 유형에 대한 피드백을 수집하여 나중에 최적화를 해당 유형에 맞게 조정합니다. 이 정보는 피드백 벡터에 저장되며, 이는 V8 힙 메모리 사용량의 상당 부분을 차지합니다. Lite 모드는 이러한 피드백 벡터의 할당을 피할 수 있었지만, 인터프리터와 V8의 인라인 캐시 인프라의 일부는 피드백 벡터가 있기를 기대했습니다. 그래서 이러한 피드백 없는 실행을 지원하기 위해 상당한 재구성이 필요했습니다.
Lite 모드는 V8 v7.3에서 출시되었으며, 코드 최적화를 비활성화하고 피드백 벡터를 할당하지 않으며 드물게 실행되는 바이트코드의 노화를 수행(아래에 설명)함으로써 V8 v7.1에 비해 일반 웹 페이지 힙 크기를 22% 줄였습니다. 이는 성능 대신 더 나은 메모리 사용량을 필요로 하는 애플리케이션에 좋은 결과입니다. 그러나 이 작업을 진행하면서, V8을 더 게으르게 만들어 성능에 영향을 주지 않고 Lite 모드의 대부분의 메모리 절감을 달성할 수 있다는 것을 깨달았습니다.
게으른 피드백 할당
피드백 벡터 할당을 완전히 비활성화하면 V8의 TurboFan 컴파일러가 코드를 최적화하는 것을 방지할 뿐만 아니라, V8이 Ignition 인터프리터에서 객체 속성 로드와 같은 일반적인 작업의 인라인 캐싱을 수행하지 못하도록 차단합니다. 따라서 이렇게 하면 V8의 실행 시간이 크게 감소하고 페이지 로드 시간이 12% 감소하며, 일반적인 인터랙티브 웹 페이지 시나리오에서 V8이 사용하는 CPU 시간이 120% 증가하는 성능 저하를 초래했습니다.
이러한 성능 저하 없이 일반 V8에서도 이러한 절감을 대부분 구현하기 위해, 대신 함수가 일정량의 바이트 코드를 실행한 후(현재 1KB) 피드백 벡터를 지연 할당하는 접근 방식으로 전환했습니다. 대부분의 함수는 자주 실행되지 않으므로 대부분의 경우 피드백 벡터 할당을 피할 수 있지만, 필요할 때 빠르게 할당하여 성능 저하를 방지하고 여전히 코드를 최적화할 수 있도록 했습니다.
이 접근 방식과 관련된 추가 복잡성은 피드백 벡터가 트리를 형성한다는 점과 관련이 있습니다. 내부 함수의 피드백 벡터가 외부 함수의 피드백 벡터에 항목으로 포함되어 있기 때문입니다. 이는 동일한 함수에 대해 생성된 모든 클로저가 동일한 피드백 벡터 배열을 받도록 하기 위해 필요합니다. 피드백 벡터를 지연 할당하는 경우, 외부 함수가 내부 함수보다 먼저 피드백 벡터를 할당할 것이라는 보장이 없으므로 피드백 벡터를 사용하여 이 트리를 형성할 수 없습니다. 이를 해결하기 위해 새로운 ClosureFeedbackCellArray
를 만들어 이 트리를 유지하고, 함수가 자주 실행되면 해당 함수의 ClosureFeedbackCellArray
를 완전한 FeedbackVector
로 교체합니다.
랩 실험 및 현장 원격 측정 결과, 데스크톱에서 지연 피드백으로 인해 성능 저하가 없음을 확인했으며, 모바일 플랫폼에서는 가비지 수집 감소로 인해 저사양 장치에서 실제 성능이 향상되는 것을 확인했습니다. 따라서, 원래의 피드백 미할당 방식에 비해 약간의 메모리 회귀가 있더라도 실제 성능 개선으로 이를 상쇄할 수 있는 라이트 모드를 포함하여 모든 V8 빌드에서 지연 피드백 할당을 활성화했습니다.
지연 소스 위치
JavaScript에서 바이트코드를 컴파일 할 때, 바이트코드 시퀀스를 JavaScript 소스 코드의 문자 위치와 연결하는 소스 위치 테이블이 생성됩니다. 그러나 이 정보는 예외를 상징화하거나 디버깅과 같은 개발자 작업을 수행할 때만 필요하므로 거의 사용되지 않습니다.
이 낭비를 피하기 위해, 이제 소스 위치를 수집하지 않고 바이트코드를 컴파일합니다(디버거나 프로파일러가 첨부되지 않은 경우). 소스 위치는 실제로 호출 스택 추적이 생성될 때만 수집됩니다. 예를 들어, Error.stack
을 호출하거나 예외의 스택 추적을 콘솔에 출력할 때 그렇습니다. 이는 약간의 비용이 발생하는데 소스 위치를 생성하려면 함수가 다시 파싱되고 컴파일되어야 하기 때문입니다. 그러나 대부분의 웹사이트는 프로덕션에서 스택 추적을 상징화하지 않으므로 관찰 가능한 성능 영향을 받지 않습니다.
이 작업과 관련하여 우리가 해결해야 했던 문제 중 하나는 반복 가능한 바이트코드 생성을 요구하는 것이었습니다. V8이 소스 위치를 수집할 때 생성된 바이트코드가 원래 코드와 다른 경우, 소스 위치가 정렬되지 않아 스택 추적이 소스 코드의 잘못된 위치를 가리킬 수 있습니다.
특정 상황에서는 V8이 함수가 즉시 또는 지연 컴파일되었는지에 따라 다른 바이트코드를 생성할 수 있었으며, 이는 함수의 초기 즉시 파싱과 이후 지연 컴파일 사이에 일부 구문 분석 정보가 손실되었기 때문입니다. 이러한 불일치는 대부분 중요하지 않았으며, 예를 들어 변수가 불변임을 추적하지 못해 최적화할 수 없게 되는 정도였습니다. 그러나 이 작업으로 인해 발견된 일부 불일치는 특정 상황에서 올바르지 않은 코드 실행을 초래할 가능성이 있었습니다. 결과적으로, 이러한 불일치를 수정하고, 테스트 및 스트레스 모드를 추가하여 함수의 즉시 및 지연 컴파일이 항상 일관된 출력을 생성하도록 보장했습니다. 이를 통해 V8의 구문 분석기 및 준비 구문 분석기의 정확성과 일관성에 대한 신뢰가 증가했습니다.
바이트코드 플러시
JavaScript 소스에서 컴파일된 바이트코드는 V8 힙 공간에서 상당한 부분(약 15%, 관련 메타데이터 포함)을 차지합니다. 초기화 중에만 실행되거나 컴파일된 후 거의 사용되지 않는 함수가 많이 있습니다.
따라서 최근에 실행되지 않은 함수의 바이트코드를 가비지 컬렉션 중에 플러시하도록 지원을 추가했습니다. 이를 위해 함수 바이트코드의 경과 시간을 추적하고, 주요 가비지 컬렉션(마크-컴팩트)마다 경과 시간을 증가시키며, 함수가 실행될 때 이를 0으로 초기화합니다. 경과 시간이 특정 임계값을 초과한 바이트코드는 다음 가비지 컬렉션 시 수집 대상이 됩니다. 수집된 후 다시 실행되면 바이트코드는 재컴파일됩니다.
바이트코드가 더 이상 필요하지 않을 때만 플러시되도록 하는 기술적 과제가 있었습니다. 예를 들어, 함수 A
가 다른 장기 실행 함수 B
를 호출하면, 함수 A
는 스택에 있는 동안 나이를 먹을 수 있습니다. 우리는 장기 실행 함수 B
가 반환될 때 함수 A
로 돌아갈 필요가 있기 때문에 함수 A
의 바이트코드를 나이 제한에 도달하더라도 플러시하고 싶지 않습니다. 따라서 바이트코드는 나이 제한에 도달하면 함수로부터 약하게 유지되지만, 스택 또는 기타 참조에서 이를 강하게 유지됩니다. 강한 연결이 남아 있지 않을 때만 코드를 플러시합니다.
바이트코드를 플러시하는 것 외에도, 이러한 플러시된 함수에 연결된 피드백 벡터도 플러시합니다. 그러나 바이트코드와 같은 GC 사이클 동안 피드백 벡터를 플러시할 수 없는 이유는 동일한 객체에 의해 유지되지 않기 때문입니다. 즉, 바이트코드는 네이티브 컨텍스트 독립적인 SharedFunctionInfo
에 의해 유지되고, 피드백 벡터는 네이티브 컨텍스트 종속적인 JSFunction
에 의해 유지됩니다. 결과적으로 피드백 벡터는 그 다음 GC 사이클에서 플러시합니다.
추가 최적화
이런 대규모 프로젝트에 더해, 몇 가지 비효율성을 발견하고 수정했습니다.
첫 번째로 FunctionTemplateInfo
객체의 크기를 줄였습니다. 이러한 객체는 FunctionTemplate에 대한 내부 메타데이터를 저장하며, Chrome과 같은 임베더가 JS 코드로 호출할 수 있는 함수의 C++ 콜백 구현을 제공하는 데 사용됩니다. Chrome은 DOM 웹 API 구현을 위해 많은 FunctionTemplates를 도입하므로 FunctionTemplateInfo
객체는 V8 힙 크기에 영향을 미쳤습니다. FunctionTemplates의 일반적인 사용을 분석한 결과, FunctionTemplateInfo
객체에는 11개의 필드 중 세 가지 필드만 기본값이 아닌 값으로 설정되는 경우가 많았습니다. 드물게 사용되는 필드가 요구될 경우에만 할당되는 측테이블에 저장하도록 FunctionTemplateInfo
객체를 분할했습니다.
두 번째 최적화는 TurboFan 최적화 코드에서 어떻게 디옵티마이즈하는지와 관련됩니다. TurboFan이 사설 최적화를 수행하면 특정 조건이 더 이상 유효하지 않을 경우 인터프리터로 다시 돌아가야 합니다(디옵티마이즈). 각 디옵트 포인트는 런타임이 인터프리터에서 실행을 반환해야 하는 바이트코드 내 위치를 결정할 수 있도록 하는 ID를 가지고 있습니다. 이전에는 최적화된 코드가 큰 점프 테이블 내 특정 오프셋으로 점프하여 올바른 ID를 레지스터에 로드한 후 런타임으로 점프하여 디옵티마이즈를 수행했습니다. 이 방식은 각 디옵트 포인트에 대해 최적화된 코드에서 점프 명령어 하나만 필요하다는 이점이 있었습니다. 그러나 디옵티마이즈 점프 테이블은 사전 할당되었으며 전체 디옵티마이즈 ID 범위를 지원할 만큼 충분히 커야 했습니다. 대신, TurboFan을 수정하여 최적화된 코드에서 디옵트 포인트가 런타임 호출 전에 디옵트 ID를 직접 로드하도록 했습니다. 이는 최적화된 코드 크기가 약간 증가하는 대가로 이러한 큰 점프 테이블을 완전히 제거할 수 있게 했습니다.
결과
우리는 지난 7번의 V8 릴리스 동안 위에서 설명한 최적화를 출시했습니다. 일반적으로 첫 번째로 Lite 모드에서 도입되었고, 후에 V8의 기본 구성으로 도입되었습니다.
이 기간 동안 우리는 일반적인 웹사이트 범위에서 평균적으로 V8 힙 크기를 18% 줄였습니다. 이는 저가형 AndroidGo 모바일 장치의 경우 평균적으로 1.5MB 감소를 의미합니다. 이는 벤치마크나 실제 웹페이지 상호작용에서 JavaScript 성능에 아무런 유의미한 영향을 미치지 않으면서 가능했습니다.
Lite 모드는 함수 최적화를 비활성화하여 JavaScript 실행 처리량에 약간의 비용이 들면서 추가 메모리 절감 효과를 제공합니다. 평균적으로 Lite 모드는 22%의 메모리 절감을 제공하며, 일부 페이지는 최대 32%의 감소를 경험할 수 있습니다. 이는 AndroidGo 장치에서 V8 힙 크기가 1.8MB 감소하는 것을 의미합니다.
개별 최적화의 영향을 기준으로 분석하면 각 페이지가 이러한 최적화에서 얻는 이익 비율이 다르다는 것이 명확합니다. 향후에는 JavaScript 실행 속도를 유지하면서도 V8의 메모리 사용량을 추가로 줄일 수 있는 잠재적 최적화를 계속 식별할 것입니다.