V8이란?

  • 독일 구글 개발 센터에서 만들어진 JavaScript 엔진입니다.

  • 웹 브라우저 안에서 실행되는 JavaScript의 성능을 높이기 위해 처음 고안되었습니다.

  • 오픈 소스이고 C++로 작성되었습니다.

  • 구글 크롬과 Node.js의 런타임에서 사용 중입니다.

  • 속도를 높이기 위해서 V8은 인터프리터를 이용하는 대신 기계어 코드로 번역합니다.

    JIT(Just-In-Time) 컴파일러를 적용하여 JavaScript 코드를 실행할 때 컴파일하여 기계어 코드로 만듭니다.

    다른 엔진과의 가장 큰 차이는 바이트코드 또는 다른 중간 코드를 생성하지 않습니다.

컴파일러

  • 5.9 버전이 출시 되기 전에는 V8에서 두 개의 엔진을 사용했습니다.

  • 풀코드젠: 간단하고 매우 빠른 컴파일러로서 단순하고 상대적으로 느린 머신 코드를 생산합니다.

  • 크랭크샤프트: 좀 더 복잡한 최적화 컴파일러로서 고도로 최적화된 코드를 생산합니다.

    자바스크립트 코드를 처음으로 수행할 때 V8은 풀코드젠을 이용해서 파싱 된 자바스크립트 코드를 변형 없이 직접 머신 코드로 번역합니다. 이를 통해 머신 코드의 실행을 매우 빠르게 시작할 수 있습니다.

    V8은 이와 같이 중간 바이트코드를 이용하지 않기 때문에 인터프리터가 필요 없게 됩니다.

    코드가 얼마간 수행된 다음 프로파일러 쓰레드는 충분한 데이터를 얻게 되고 어떤 메소드를 최적화할 지 알 수 있게 됩니다.

    그러면 크랭크샤프트가 다른 쓰레드에서 최적화를 시작합니다.

    크랭크샤프트는 자바스크립트의 추상구문트리를 고수준 정적단일할당(static single-assignment, SSA)으로 번역하는데 이를 하이드로젠(Hydrogen)이라고 부릅니다. 크랭크샤프트는 또한 하이드로젠 그래프를 최적화하고자 노력하기도 합니다. 대부분의 최적화가 이 수준에서 이루어집니다.

    하이드로젠 그래프가 최적화되면 크랭크샤프트는 이를 리튬이라고 부르는 더 하위레벨로 낮춥니다. 리튬의 대부분의 구현은 아키텍쳐에 따라 다릅니다. 레지스터 할당이 이 수준에서 이루어집니다.

    그 후 리튬은 머신 코드로 컴파일됩니다. 그런 다음 OSR(on-stack replacement, 온스택교환)이라는 것이 일어납니다. 수행시간이 긴 메소드를 컴파일하고 최적화하기 전에 실행할 가능성이 높습니다.

    V8은 더 최적화된 버전으로 다시 시작하기 위해 방금 어떤 코드가 느리게 수행됐는지 잊지 않습니다. 우리가 가진 모든 맥락(스택, 레지스터 등)을 전환하여 코드의 수행 중간에 최적화된 버전으로 옮겨탈 수 있도록 해줍니다. 이를 V8이 시작부터 코드를 인라인하고 기타 최적화를 수행한 것을 생각하면 매우 복잡한 작업입니다. 하지만 V8이 이러한 작업을 수행하는 유일한 엔진은 아닙니다.

    V8엔진이 내린 가정이 더 이상 유효하지 않는 경우에 대비해 반최적화(deoptimization)라는 보호장치가 존재합니다. 이는 반대로의 변형을 수행하여 최적화되지 않은 코드를 되돌려 놓습니다.

쓰레드를 사용

  • 메인 스레드
  • 컴파일을 위한 별도의 스레드
  • 프로파일러 스레드
  • 그 외 가비지 컬렉터 스윕을 처리하기 위한 몇 개의 스레드

메인 쓰레드 : 코드를 가져와서 컴파일하고 실행하는 곳입니다.

컴파일을 위한 별도의 쓰레드 : 별도의 쓰레드가 코드를 최적화하는 동안 메인 쓰레드는 쉬지 않고 코드를 수행할 수 있습니다.

프로파일러 쓰레드 : 어떤 메소드에서 사용자가 많은 시간을 보내는지 런타임에게 알려주어 크랭크샤프트(최적화 컴파일러로서 고도로 최적화된 코드를 생산함)가 이들을 최적화할 수 있게 해줍니다.

최적화

Hidden class

  • 히든클래스는 자바와 같은 언어에서 사용되는 고정 객체 레이아웃과 유사하게 작동하는데 다만 런타임에 생성된다는 차이점이 있습니다.V8은 생성자 함수가 프로퍼티를 정의할 때마다 새로운 히든 클래스를 생성하고 히든 클래스의 변화를 추적합니다.

    대부분의 자바스크립트 인터프리터가 딕셔너리와 유사한 구조(해쉬함수 기반)를 이용해 객체 속성 값의 위치를 메모리에 저장합니다. 이러한 구조 때문에 자바스크립트의 속성 값을 가져오는 것은 자바나 C#에서 보다 계산적으로 더 비싼 행동이 됩니다.

  • V8은 객체에 새로운 프로퍼티를 추가할 때 hidden class를 생성하고, hidden class에 프로퍼티의 정적인 위치(offset)를 저장함으로써 실제 데이터가 저장되어 이는 위치에 대한 Pointer를 제공합니다. 이로 인해 런타임에 데이터접근이 필요 없어지고, 고전적인 클래스 기반의 최적화를 할 수 있습니다. (위치 정보 해석할 필요가 없어져서 빨라집니다)

어떤 객체에 새로운 속성이 추가될 때마다 오래된 히든클래스는 새로운 히든 클래스에 대한 전환 경로로 업데이트 됩니다. 히든클래스 전환이 중요한 이유는 이를 통해 히든 클래스가 같은 방식으로 생성된 객체들 사이에 공통으로 사용될 수 있기 때문입니다. 만약 두 개의 객체가 하나의 히든클래스를 공유하고 같은 속성이 이들에게 추가되면 전환과정은 이들 객체가 동일한 새로운 히든클래스를 받도록 하고 그에 따라 그에 딸려 오는 최적화 코드도 모두 동일합니다.

hiddenClass

히든클래스 전환은 속성이 객체에 추가되는 순서에 의존적입니다.

인라이닝

  • 인라이닝이란 호출 지점(함수가 호출된 곳의 코드 위치)을 호출된 함수의 내용으로 바꾸는 과정입니다. 이러한 단순한 과정으로 이후의 최적화가 더욱 큰 의미를 가지게 됩니다.

inlining

인라인 캐싱

  • 인라인 캐싱은 같은 메소드에 대한 반복되는 호출은 같은 타입의 객체에서 이루어진다는 관찰 결과에 의존합니다.
  • 인라인캐싱은 같은 타입의 객체가 히든클래스를 공유하는 게 중요한 이유이기도 합니다. 만약 타입은 같고 히든 클래스는 다른 두 객체를 만들면 V8은 인라인캐싱을 사용할 수 없을 것입니다. 왜냐하면 두 객체가 같은 타입이기는 해도 각각에 대응하는 히든클래스가 그들의 속성에 서로 다른 오프셋을 할당하기 때문입니다.
  • 같은 메소드에 대한 반복되는 호출은 같은 타입의 객체에 이루어진다는 결과로 진행 됩니다.
  • 객체 필드에 접근을 할 때 hidden class를 사용한다면 결국 우리가 얻고 싶은 것은 접근하려는 필드의 오프셋 값입니다. 간단히 말하면 인라인 캐싱은 이 오프셋 값을 캐싱하겠다는 이야기 입니다.

V8은 최근 메소드 호출에 파라미터로 전달된 객체 타입의 캐시를 유지하고 이 정보를 이용해 앞으로 파라미터로 넘어올 객체의 타입에 대한 가정을 합니다. 만약 V8이 메소드에 전달될 객체 타입에 대한 가정을 잘 할 수 있으면 객체의 속성에 접근할 방법을 알아내는 과정을 수행하지 않아도 되며 그 대신 객체의 히든 클래스에 대해 이전에 찾아서 저장 했던 정보를 사용할 수 있습니다.

특정 객체에 메소드가 호출될 때마다 V8엔진은 특정 속성에 접근하기 위한 오프셋을 계산하기 위해 해당 객체의 히든클래스를 뒤져봐야 합니다. 동일한 히든 클래스의 동일한 메소드에 대해 두 번의 성공적인 호출을 마치고나면 V8은 히든클래스를 찾는 것을 생략하고 단순하게 스스로 해당 객체 포인터에 속성 오프셋을 더해 놓습니다. 이후 해당 메소드에 대한 모든 호출에 대해 V8은 히든클래스는 변하지 않았다고 가정하고 이전에 찾아 두었던 오프셋을 이용해 직접 메모리 주소로 점프합니다. 이를 통해 실행 속도는 크게 증가합니다.

가비지 컬렉션

  • 가비지컬렉션을 대해 V8은 전통적인 마킹하고 쓸어버리기(mark-and-sweep)의 세대적 접근방법을 이용해 예전 세대를 제거합니다.

마킹 단계에서는 자바스크립트의 수행을 중단하게 되어있습니다. 가비지컬렉션 비용을 통제하고 그 수행을 좀 더 안정적으로 하기위해 V8은 점진적 마킹을 이용합니다. 힙 전체를 훑어서 가능한 모든 객체를 마킹하는 대신 힙의 일부만을 확인한 다음 정상적인 자바스크립트 실행을 계속합니다. 그 다음의 GC 수행은 바로 이전에 멈춘곳에서부터 계속됩니다. 이를 통해 일상적인 실행에는 매우 짧은 코드 중단만 일어납니다. 위에 언급한대로 쓸어버리기는 별도의 쓰레드에서 수행됩니다.

이그니션과 터보팬

  • 2017년 초 5.9의 배포와 더불어 새로운 실행 파이프라인이 소개되었습니다. 이 새로운 파이프라인은 더 큰 성능 향상을 가져오며 실제 자바스크립트 응용프로그램에서 현저하게 메모리를 절약할 수도 있습니다.
  • 새로운 실행 파이프라인은 V8의 인터프리터인 이그니션과 새로운 최적화 컴파일러인 터보팬 위에 만들어졌습니다.
  • V8의 5.9 버전이 출시된 이후, 풀코드젠과 크랭크샤프트(2010년부터 V8에서 사용되고 있던 기술들)는 V8에서 자바스크립트 실행에 사용되지 않고 있습니다. 왜냐하면 V8팀이 새로운 자바스크립트 언어 기능과 이러한 기능에 필요한 최적화 필요에 대응하는데 애를 먹고 있기 때문입니다.
  • 이는 V8의 구조가 앞으로는 훨씬 단순하고 유지보수가 용이하게 되었다는 것을 의미합니다.

최적화를 위한 권장사항

Hidden class

  • V8이 각 프로퍼티에 대해 새로운 히든 클래스를 생성하기 때문에 히든 클래스는 최소한으로 생성되어야 합니다. 그러기 위해선 오브젝트가 생성된 후에 프로퍼티를 더하는 것을 피하고 항상 오브젝트 멤버를 같은 순서로 선언해야 합니다(히든 클래스 여러 개 생성을 피하기 위해서). 단형적(monomorphic) 연산은 같은 히든 클래스인 오브젝트들에만 적용되는 연산입니다. V8은 함수를 호출할 때 히든 클래스를 생성하는데, 만약 다른 parameter 타입들로 다시 호출했다면 V8은 또다른 히든 클래스를 만들어야 한다: 다형적(polymorphic) 코드보다 단형적 코드를 선호해야 합니다.

기타

  • 메소드 : 동일한 메소드를 반복적으로 수행하는 코드가 서로 다른 메소드를 한 번씩만 수행하는 코드 보다 더 빠르게 동작합니다(인라인 캐싱 때문)
  • 배열 : 값이 띄엄띄엄 있어서 키가 계속해서 증가하는 숫자가 되지 않는 배열은 피하는게 좋습니다. 모든 요소를 가지지는 않는 배열은 해시테이블입니다. 이와 같은 배열의 요소들은 접근하기에 많은 비용이 듭니다. 또한 커다란 배열을 미리 할당하지 않도록 하십시오. 사용하면서 크기가 커지도록 하는 게 낫습니다. 마지막으로 배열의 요소를 삭제하지 마십시오. 그 배열의 키가 띄엄띄엄 배치됩니다.
  • 태깅된 값 : V8은 객체와 숫자를 32비트로 표현합니다. 어떤 값이 오브젝트(flag = 1)인지 혹은 정수(flag = 0)인지는 SMI(Small Integer)라는 하나의 비트에 저장하고 이 때문에 31비트가 남습니다. 따라서 어떤 숫자가 31비트 보다 크면 V8은 이 숫자를 분리해서 더블 타입으로 전환한 다음 이 숫자를 넣을 새로운 객체를 생성합니다. 이러한 동작은 비용이 높으므로 가능한한 31비트의 숫자를 사용하도록 하십시오.

참조

TurboFan 프로젝트는 원래 크랭크 샤프트의 단점을 해결하기 위해 2013 년 말에 시작되었습니다. 크랭크 샤프트는 JavaScript 언어의 서브셋 만 최적화 할 수 있습니다. 예를 들어, 구조적 예외 처리 (예 : JavaScript의 try, catch 및 최종 키워드로 구분 된 코드 블록)를 사용하여 JavaScript 코드를 최적화하도록 설계되지 않았습니다. Crankshaft에서 새로운 언어 기능에 대한 지원을 추가하는 것은 어렵습니다. 이러한 기능은 거의 항상 9 개의 지원되는 플랫폼에 대한 아키텍처 별 코드 작성이 필요하기 때문입니다. 또한 크랭크 샤프트의 아키텍처는 최적의 머신 코드를 생성 할 수있는 정도로 제한되어 있습니다. V8 팀이 칩 아키텍처 당 1 만 라인 이상의 코드를 유지해야하더라도 JavaScript에서 많은 성능을 발휘할 수 있습니다.

TurboFan은 처음부터 ES5의 JavaScript 표준에서 발견 된 모든 언어 기능뿐만 아니라 ES2015 이상을 위해 계획된 모든 향후 기능을 최적화하도록 설계되었습니다. 높은 수준의 컴파일러와 낮은 수준의 컴파일러 최적화를 명확하게 분리 할 수있는 계층화 된 컴파일러 설계를 도입하여 아키텍처 별 코드를 수정하지 않고도 새로운 언어 기능을 쉽게 추가 할 수 있습니다. TurboFan은 명시적인 명령어 선택 컴파일 단계를 추가하여 처음에 지원되는 각 플랫폼에 대해 아키텍처 별 코드를 훨씬 적게 작성할 수 있습니다. 이 새로운 단계에서는 아키텍처 별 코드가 한 번 작성되므로 거의 변경하지 않아도됩니다. 이러한 결정과 다른 결정은 V8이 지원하는 모든 아키텍처에 대해 유지 관리가 쉽고 확장 가능한 최적화 컴파일러로 이어집니다.

V8의 Ignition 인터프리터의 원동력은 모바일 장치의 메모리 소비를 줄이는 것이 었습니다. 점화 이전에 V8의 Full-codegen 기본 컴파일러에서 생성 된 코드는 일반적으로 Chrome에서 전체 JavaScript 힙의 거의 1/3을 차지했습니다. 따라서 웹 응용 프로그램의 실제 데이터를위한 공간이 줄었습니다. RAM이 제한된 Android 기기에서 Chrome M53에 대해 점화를 사용하도록 설정하면 최적화되지 않은 기본 자바 스크립트 코드에 필요한 메모리 공간이 ARM64 기반 휴대 기기에서 9 배 줄어 듭니다.

나중에 V8 팀은 Ignition의 바이트 코드를 사용하여 Crankshaft처럼 소스 코드에서 다시 컴파일 할 필요없이 TurboFan으로 직접 최적화 된 머신 코드를 생성 할 수 있다는 사실을 이용했습니다. Ignition의 바이트 코드는 V8에서보다 깨끗하고 오류가 발생하기 쉬운 기준선 실행 모델을 제공하여 V8의 적응 형 최적화 의 주요 기능인 역 최적화 메커니즘을 단순화합니다 . 마지막으로, 바이트 코드를 생성하는 것이 Full-codegen의 기본 컴파일 된 코드를 생성하는 것보다 빠르기 때문에, 점화를 활성화하면 일반적으로 스크립트 시작 시간이 향상되고 웹 페이지로드가 향상됩니다.

Ignition과 TurboFan의 디자인을 밀접하게 결합하면 전체 아키텍처에 훨씬 더 많은 이점이 있습니다. 예를 들어, V8 팀은 Ignition의 고성능 바이트 코드 핸들러를 직접 코딩 된 어셈블리로 작성하는 대신 TurboFan의 중간 표현 을 사용 하여 핸들러 기능을 표현하고 TurboFan이 V8의 수많은 지원 플랫폼에 대한 최적화 및 최종 코드 생성을 수행 할 수 있도록합니다. 이를 통해 Ignition은 모든 V8 지원 칩 아키텍처에서 우수한 성능을 유지하면서 동시에 9 개의 개별 플랫폼 포트를 유지 관리해야하는 부담을 제거합니다.