[JavaScript] How JavaScript works (2) - inside the V8 engine + 5 tips on how to write optimized code

14310 단어 JavaScriptJavaScript

이 글은 Alexander Zlatkov의 "
How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
"를 번역한 글입니다.

Overview

JavaScript Engine 은 자바스크립트 코드를 실행하는 인터프리터입니다. JavaScript Engine 은 코드를 실행하는 인터프리터나 JavaScript를 바이트 코드로 변환시키는 컴파일러로 실행될 수 있습니다.

아래는 유명한 JavaScirpt Engine 입니다.

  • V8: 오픈 소스이며, 구글에 의해 C++로 개발되었습니다.
  • Rhino: 오픈 소스이며, Mozilla에 의해 Java로 개발되었습니다.
  • SpiderMonkey: 첫 JavaScript Engine 으로, 전에는 Netscap Navigator, 현재는 Firefox의 엔진입니다.
  • JavaScriptCore: 오픈 소스이며, 애플에 의해 개발되었습니다. Safari의 엔진입니다.
  • KJS: Harri Porten에 의해 개발된 KDE의 엔진입니다.
  • Chakra: IE와 Edge의 엔진입니다.

Why was the V8 Engine created?

V8 Engine 은 구글에 의해 C++로 작성된 오픈 소스 엔진입니다. 현재 크롬의 JavaScript Engine 이며, 다른 엔진들과는 다르게 Node.js runtime 에도 사용됩니다.

V8 은 브라우저에서 실행되는 JavaScript의 성능을 높이기 위해 개발되었습니다. 속도를 향상시키기 위해, V8 은 인터프리터를 사용하는 대신 JavaScript 코드를 기계어로 변환시킵니다. 다시 말해서, JIT(Just-In-Time) compiler 를 사용해서 런타임에 JavaScript 코드를 기계어로 컴파일합니다. 이 방식은 SpiderMonkeyRhino 와 같은 요즘의 JavaScript Engine 과 동일한 방식입니다. V8 이 가지는 차별점은, byte code 와 같은 intermediate code 가 없다는 점입니다.

V8 used to have two compilers

V8 5.9 이전 버전의 엔진은 두 개의 컴파일러를 사용했습니다.

  • full-codegen: 간단하고 매우 빠른 컴파일러로 약간 느린 기계어로 변환합니다.
  • Crankshaft: 약간 복잡한 컴파일러로 최적화가 잘 된 기계어로 변환합니다.

V8 은 많은 쓰레드를 사용합니다.

  • Main Thread 는 코드를 fetch하고, 컴파일 하고 실행합니다.
  • 컴파일을 위한 별도의 쓰레드가 있습니다. Main Thread 에서 코드를 실행하는 동안 코드를 최적화합니다.
  • Profiler Thread 는 런타임에 어떤 메소드가 오래 걸리는 지 파악하여 Crankshaft 가 최적화 할 수 있도록 합니다.
  • 몇몇 쓰레드는 가비지 콜렉터를 관리합니다.

JavaScript 코드를 처음 실행할 때, V8full-codegen 이 파싱된 JavaScript를 기계어로 바로 변환시킵니다. 이 과정을 통해 기계어를 매우 빠르게 실행할 수 있습니다. V8 은 intermediate code를 사용하지 않아 인터프리터를 불필요하도록 했습니다.

JavaScript 코드를 여러 번 실행하면, Profiler Thread 는 어떤 메소드를 최적화시켜야 하는가에 대한 충분한 데이터를 얻게 됩니다.

Crankshaft 의 최적화 과정은 다른 쓰레드에서 실행됩니다. JavaScript abstarct syntax treeHydrogen 이라고 불리는 high-level static singe-assignment 로 변환하고, 최적화합니다. 대부분의 최적화는 이 과정에서 일어납니다.

Inlining

첫 번째 최적화는 최대한 많은 코드를 미리 inlining 하는 것입니다. Inlining 은 함수를 호출하는 부분을 함수의 내용으로 바꾸는 것입니다. 이 간단한 과정을 통해서 이어지는 최적화 과정이 더욱 의미있는 과정이 됩니다.

Inlining

Hidden class

JavaScript는 prototype-base-language 입니다. 이는 클래스가 없고 객체는 cloning process 를 통해 생성됩니다. JavaScript는 객체가 생성된 이후에도 프러퍼티가 쉽게 추가되고, 제거될 수 있는 동적언어입니다.

대부분의 JavaScript 인터프리터는 메모리에 객체의 프로퍼티의 위치를 저장하기 위해 dictionary-like 구조체(hash function based)를 사용합니다. 이 구조체 때문에 JavaScript에서 객체의 프로퍼티 값을 가져오는 과정이 다른 정적인 언어보다 비쌉니다. Java는 모든 객체의 프로퍼티는 컴파일 전에 결정되어 런타임에 동적으로 추가되거나 제거되지 못합니다. 따라서, 프로퍼티의 값은 메모리에 고정된 오프셋 값을 가진 채로 연속된 버퍼에 저장될 수 있습니다. 오프셋의 크기는 프로퍼티의 타입에 따라 쉽게 결정할 수 있습니다. 반면, JavaScript는 프로퍼티 타입이 런타임에 변경될 수 있으므로 위에 과정은 불가능합니다.

메모리에서 객체의 프로퍼티의 위치를 가져오기 위해 dictionaries 를 사용하는 것이 매우 비효율적이기 때문에, V8hidden classes 라는 다른 방법을 사용합니다. Hidden Classes 는 런타임에 생성된다는 점을 제외하면 자바와 같은 언어에서 사용하는 것과 비슷한 과정입니다.

이제, 실제로 어떻게 돌아가는지 살펴보겠습니다.

function Point(x, y) {
  this.x = x
  this.y = y
}

const p1 = new Point(1, 2)

"new Point(1,2)" 를 호출하면, V8 은 'C0' 라고 부르는 Hidden class 를 생성합니다.

Initialize Hidden Class

아직 아무 프로퍼티가 정의되지 않았으므로, 'C0'는 비어있습니다.

"this.x = x" 를 실행하면, V8 은 'C0' 에 기반을 두는 두 번째 Hidden class 'C1' 을 생성합니다. 'C1' 은 x의 오프셋을 저장합니다. 이 케이스에는, 'x' 의 오프셋은 0 입니다. 객체가 연속된 버퍼에 저장되었다고 생각하면, 첫 번째 오프셋이 'x' 를 나타내는 것을 의미합니다. V8 은 'C0' 에 "x가 추가되면 'C1' 으로 바꿔" 라는 class transition 을 추가합니다. 이제 point 객체의 Hidden class 는 'C1' 이 됩니다.

Swtich from C0 to C1

객체에 새로운 프로퍼티가 추가될 때 마다, 이전의 Hidden classtransition path 를 통해 새로운 Hidden class 로 업데이트 됩니다. Hidden class transition 이 중요한 이유는 같은 방법으로 생성된 객체들이 Hidden class 를 공유할 수 있기 때문입니다. 만약 두 객체가 Hidden class 를 공유하고, 새로운 프로퍼티가 모두 추가되면, Hidden class transition 은 두 객체에게 같은 Hidden class 와 함께 최적화된 코드를 제공합니다.

위의 과정은 "this.y = y" 가 실행될 때도 반복됩니다.

새로운 Hidden class 'C2' 가 생성되고, 'C1' 에는 "y가 추가되면 'C2'로 바꿔" 라는 class transition 이 추가됩니다. 그리고 point 객체의 Hidden class 는 'C2' 가 됩니다.

Swtich from C1 to C2

Hidden class transition 은 객체에 프로퍼티가 추가되는 순서에 따라 달라집니다. 아래의 코드를 살펴봅시다.

function Point(x, y) {
  this.x = x
  this.y = y
}

const p1 = new Point(1, 2)
p1.a = 5
p1.b = 6

const p2 = new Point(3, 4)
p2.b = 7
p2.a = 8

이제 'p1' 과 'p2' 가 같은 Hidden classtransition path 를 가진다고 생각하지만, 실제는 다릅니다. 'p1' 은, 'a' 가 추가되고 'b' 가 추가됩니다. 반면, 'p2' 는 'b' 가 추가되고 'a' 가 추가됩니다. 따라서, 'p1' 과 'p2' 는 다른 Hideen class , 그리고 다른 trnasition path 를 가집니다. 이러한 경우에는, 동적으로 추가되는 프로퍼티를 모두 통일하여 Hidden class 를 재사용할 수 있도록 하는 것이 좋습니다.

Inline caching

V8inline caching 이라 불리는 동적 언어의 최적화 기술을 사용합니다. Inline caching 은 같은 메소드를 반복해서 호출하면 대부분 같은 타입의 객체를 리턴한다는 점에 기반합니다. Inline caching 에 대한 더욱 깊은 설명은 여기서 확인할 수 있습니다.

V8 은 최근의 메소드 호출에서 파라미터로 전달된 객체의 타입을 캐싱하고, 이를 전달될 파라미터의 타입을 추론하는 데 사용합니다. 만약 V8 의 추론이 성공적이라면, 객체의 프로퍼티에 접근하는 방법을 알아내는 과정 대신 객체의 Hidden class 에 접근할 때 캐싱했던 정보를 사용합니다.

Hidden classInline caching 어떻게 연결되어 있는 것일까요? 특정 객체의 메소드가 호출될 때 마다, V8 은 프로퍼티의 오프셋을 알기 위해서 객체의 Hidden class 에 접근해야 합니다. 같은 Hidden class 의 같은 메소드를 두 번 호출하면, V8Hidden class 에 접근하는 과정을 생략하고 객체 포인터에 프로퍼티의 오프셋을 바로 더합니다. 이어지는 메소드 호출에서, V8Hidden class 에 변화가 없다고 가정하고, 이전 접근으로부터 저장한 오프셋을 사용하여 프로퍼티의 메모리에 바로 접근합니다. 이 과정을 통해 실행 속도를 크게 증가시킬 수 있습니다.

Inline caching 은 같은 타입의 객체가 Hidden class 를 공유해야하는 또 다른 중요한 이유입니다. 만약 같은 타입의 객체가 다른 Hidden class 를 사용한다면, 서로 다른 오프셋을 가지고 있기 때문에 V8 은 같은 타입임에도 불구하고 Inline caching 을 할 수 없습니다.

Basically same object, but diffrent Hidden class

Compilation to machine code

Hydrogen graph 가 최적화되면, Crankshaft 는 이를 Lithium 이라 불리는 로우레벨로 변환합니다. 일반적으로 Lithium의 구현방식은 아키텍처에 기반합니다. 레지스터 할당은 이 과정에서 진행합니다.

마지막으로, Lithium 은 기계어로 컴파일됩니다. 이제 OSR(on-stack replacement) 라고 불리는 과정을 진행합니다. 매우 오래 걸리는 메소드를 컴파일하고 최적화하기 전에는, 우리는 이것을 그냥 실행했을 확률이 높습니다. V8 은 최적화된 버전으로 실행하기 위해서 메소드가 오래 걸렸다는 사실을 잊지 않을 것 입니다. 대신, 실행 도중에 최적화된 버전으로 전환할 수 있도록 모든 컨텍스트를 변환합니다. 이는 다른 최적화 과정을 고려한 매우 복잡한 과정이며, V8 은 처음에 Inlining 과정을 진행했습니다. V8 은 이 과정을 진행할 수 있는 유일한 엔진이 아닙니다.

엔진이 제대로된 최적화를 진행하지 못하는 경우, 이전 코드로 돌아가는 deoptimization 이라는 과정도 있습니다.

Garbage collection

V8mark-and-sweep 이라는 전통적인 Garbage collection 방식을 사용합니다. Marking phase 에서는 JavaScript 실행을 중단합니다. Gargabe collection 비용 조절과 실행을 더욱 안전하게 하기 위해서, V8incremental marking 을 사용합니다. 이는 힙을 모두 순회하여 사용 중인 객체를 마킹하는 것이 아니라, 일부분만 순회하고 JavaScript 실행을 이어갑니다. 다음 단계에서 중단했던 힙 순회를 이어갑니다. 이는 JavaScript를 아주 잠시동안만 중단하게 합니다. 위에서 언급했듯이, Sweep phase 는 다른 쓰레드에서 진행합니다.

Ignition and TurboFan

2017년 V8 5.9 를 릴리즈하면서, 새로운 실행 파이프라인이 추가되었습니다. 새로운 파이프라인은 실제 JavaScript 어플리케이션에게 큰 퍼포먼스 향상과 메모리 절약을 가져다 주었습니다.

새로운 파이프라인은 V8 의 인터프리터인 IgnitionV8 의 새로운 최적화 컴파일러인 TurboFan 을 기반으로 합니다.

V8 팀의 이 주제에 관한 포스트를 여기서 확인할 수 있습니다.

V8 5.9 를 릴리즈한 후, Full-codegenCrankshaftV8 에서 JavaScript를 실행하는 데 더 이상 사용되지 않습니다. 이는 V8 팀이 새로운 기능을 추가하면서 이에 대한 최적화 과정이 필요했기 때문입니다.

이는 전체적으로 V8 이 매우 간단해졌고, 유지보수가 용이한 아키텍쳐가 됐음을 의미합니다.

Improvements on Web and Node.js benchmarks

이러한 발전은 시작일 뿐입니다. IgnitionTurboFan 파이프라인은 JavaScript의 성능을 향상시키는 추가적인 최적화를 위한 기반을 마련할 것입니다.

마지막으로, 아래는 최적화된 JavaScript 코드를 작성하는 몇 가지 팁입니다.

How to write optimized JavaScript

  1. Order of object properties: 객체의 프로퍼티를 항상 같은 순서로 초기화하여, Hidden class 를 공유해야 합니다.
  2. Dynamic properties: 객체가 초기화된 이후 프로퍼티를 추가하면, Hidden class 에 변화를 발생시켜, 이전의 Hidden class 에 맞춰 최적화된 메소드의 속도를 감소시킵니다. 이러한 상황을 방지하기 위해, 모든 프로퍼티는 객체의 생성자에 정의하도록 합니다.
  3. Methods: 동일한 메소드를 여러 번 호출하는 것은 Inline caching 덕분에 여러 메소드를 한 번씩 호출하는 것보다 속도가 빠릅니다.
  4. Arrays: Sparse Array 의 사용은 줄여야 합니다. 모든 원소를 가지지 않는 Sparse ArrayHash table 과 동일합니다. 배열의 원소에 접근하는 것은 비싼 과정입니다. 뿐만 아니라, 큰 배열을 미리 할당하는 것도 피해야 합니다. 마지막으로, 배열에서 원소를 삭제하는 것도 피해야합니다.
  5. Tagge values: V8 은 object와 number를 32비트로 표현합니다. 이 둘을 구분하기 위해 flag 비트를 사용하는데, 이를 SMI(SMall Integer) 라고 부릅니다. 만약, 31비트보다 큰 숫자를 사용하면, V8 은 숫자를 double로 전환하고 숫자를 저장하기 위해 새로운 객체를 생성합니다. 이 과정은 비싸기 때문에, 최대한 31비트로 표현 가능한 숫자를 사용해야 합니다.



참고 자료
How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code

좋은 웹페이지 즐겨찾기