ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [2020.10.16] Google Chrome V8 엔진을 파헤쳐보자
    개발 블로깅/기타 개념 2020. 10. 17. 00:56

    Google V8 엔진

    V8Google ChromeNode.js에서 사용되고 있는 구글에서 제작한 자바스크립트 엔진이다.

    C++로 작성되었고 고성능의 자바스크립트 전용의 웹 어셈블리 엔진이라 할 수 있다. 일반적으로 자바스크립트 엔진은 코드 한 줄을 해석하고 바로 실행하는 인터프리터 형식이지만, V8 엔진은 자바스크립트 코드를 바이트코드(ByteCode)로 컴파일하고 실행하는 방식을 사용한다.

    또한 V8엔진은 독립형으로 개발되었기 때문에 웹 브라우저 뿐 아니라 C++ 프로그램에 별도로 내장하여 실행시킬 수도 있다.

    이러한 점은 엄청나게 강력한 기능이다. C++자체가 하드웨어 레벨에 훨씬 더 가까운 언어인 만큼 자바스크립트보다 더 많은 특성을 가지고 있다. 그렇기에 C++로 작성한 언어를 V8엔진을 이용하여 자바스크립트에서 동작하도록 한다면, 순수 자바스크립트로만 할 수 있는 것보다 훨씬 더 많은 일을 할 수 있는 것이다.

    그러면 V8 엔진이 실제로 어떻게 구성이 되어있고 어떻게 동작하는지 알아보도록 하자.

     

    과거 V8 5.9 이하의 엔진 구조

    우선 과거의 V8 엔진 구조와 현재의 엔진 구조에 차이가 있다고 한다. 과거에는 V8엔진 내부의 기능들이 좋은 성능을 보여주지 못해서 어쩔 수 없이 추가로 사용되는 모듈들이 있었는데, 현재는 각 기능들이 발달하면서 더 이상 필요로 하지 않은 기능들을 제거함으로써 더욱 가벼워지고 성능도 향상되었다.

    그러면 v5.9 이하의 버전과 현재의 버전에 대한 구조가 어떻게 변화되었는지 잠깐 확인해보자.

    # v5.9 이하의 V8 엔진 구조

    v5.9 이하의 V8 엔진에는 Full-codegenCrankshaft라는 컴파일러가 있었다.

    Full-codegenIgnition과 같이 자바스크립트 코드를 바이트 코드로 변환하여 실행 속도를 빠르게 하기 위한 용도로 사용하던 컴파일러이며, Crankshaft 또한 TurboFan과 같이 바이트 코드를 최적화된 코드로 컴파일을 하는 컴파일러이다.

    그러나 V8엔진의 원래 목적은 처음부터 Ignition과 TurboFan만을 이용하려 했었는데, 초창기에 이 두개의 성능이 많이 떨어져서 어쩔 수 없이 함께 사용하게 되었으며, 그로 인해 현재보다 훨씬 복잡한 구조를 가지고 있있다.

    # 현재의 V8 엔진 구조

     

    그러나 V8 엔진이 계속해서 진화하면서 Ignition과 TurboFan의 성능이 좋아지며, 반대로 Full-codegen과 Crankshaft는 성능이 받쳐주지 못하게 되어 5.9 버전부터는 이 두 가지를 제거하게 되었다고 한다.

     

    그러면 이러한 V8 엔진이 실제로 어떻게 동작하는지 알아보도록 하자.

     

    V8 엔진의 동작 과정

     

     

    소스코드 파싱

    자바스크립트 소스코드를 가져와서 먼저 파서(Parser)에게 넘기면, 소스코드를 분석한 후 AST(Abstract Syctax Tree, 추상 구문 트리)로 변환한다.

    아래 코드를 한번 봐보자.

    function hello (name){
      return 'Hello,' + name;
    }
    
    ---------------------------------
    
    // 구조화 된 AST
    {
      type: 'FunctionDeclaration',
      name: 'hello'
      arguments: [
        {
          type: 'Variable',
          name: 'name'
        }
      ]
      // ...
    }

     

    예를 들어 위 코드를 보면, 해당 함수가 파싱이 되면 hello 함수 아래에 AST 트리 형태처럼 의미 있는 Tree 형태로 변환한다고 생각하면 된다.
    실제로는 내부적으로는 이뿐 아니라
     'function', 'hello', '('....으로 각 어휘와 문법을 모두 해석하고 파싱을 하게 되므로, 실제 파서 내부는 엄청나게 많은 동작을 하게 된다.

    이렇게 파싱 되어 만들어진 AST는 Ignition으로 넘어가게 된다.

     

    Ignition 바이트 코드(ByteCode)로 변환

     

    Ignition

    Ignition은 자바스크립트 코드를 바이트 코드(ByteCode)로 변환하는 인터프리터이다. 원본 소스 코드보다 컴퓨터가 해석하기 쉬운 바이트 코드로 변환하여, 수시로 코드를 파싱(Parsing)하는 작업을 최소화하고 코드의 양도 줄임으로써 메모리 공간도 효율적으로 관리할 수 있게 된다.

     

     

    자바스크립트는 정적 타이핑 언어가 아니 동적 타이핑 언어라서 소스코드가 실행되기 전에는 알 수 없는 값들이 너무 많아 최적화가 힘들다는 단점이 있다. 때문에 모든 소스를 한 번에 해석하는 컴파일 방식이 아닌 코드 한 줄 한줄 실행될 때마다 해석하는 인터프리트 방식을 채택하면서 다음 세 가지 이점을 가져가고자 하였다.

    • 메모리 사용량 감소 : 자바스크립트 코드에서 기계어로 컴파일하는 것보다 바이트 코드로 컴파일하는 것이 더 편하다.
    • 파싱 시 오버헤드 감소 : 바이트 코드는 간결하기 때문에 다시 파싱 하기 편하다.
    • 컴파일 파이프라인의 복잡성 감소 : TurboFan을 통한 Optimizing 혹은 Deoptimizing 처리 시에도 바이트 코드가 편하다. (TurboFan 관련 내용은 아래에 더 자세히 다룬다.)

     

    여기서 약간 헷갈릴 수 있는 부분이 있을 것이다.
    기계어와 바이트 코드의 차이가 무엇일까? 그리고 기계어보다 바이트 코드가 더 편하다는 말이 무슨 뜻일까?

    아래 예시를 통해 한번 살펴보자.

    # 소스코드

    function hello(name) {
      return "Hello," + name;
    }
    console.log(hello("Inyong"));

    # 코드 실행

    $ node --print-bytecode test.js

     

    위처럼 node 실행 시 '--print-bytecode'를 옵션으로 실행시키면, 해당 함수가 바이트코드로 어떻게 인터프리팅 되는지 확인할 수 있다.

     

    일부를 가져와서 한번 살펴보자.

    16505 E> 0xd9d80ae32f6 @    0 : a7                StackCheck 
    16516 S> 0xd9d80ae32f7 @    1 : 1b 3e             LdaImmutableCurrentContextSlot [62]
    16528 E> 0xd9d80ae32f9 @    3 : ac 00             ThrowReferenceErrorIfHole [0]
    16527 E> 0xd9d80ae32fb @    5 : 2a 02 00          LdaKeyedProperty <this>, [0]
             0xd9d80ae32fe @    8 : 26 fb             Star r0
    16537 E> 0xd9d80ae3300 @   10 : 28 fb 01 02       LdaNamedProperty r0, [1], [2]
             0xd9d80ae3304 @   14 : 97 04             JumpIfToBooleanTrue [4] (0xd9d80ae3308 @ 18)
             0xd9d80ae3306 @   16 : 12 02             LdaConstant [2]
    16548 S> 0xd9d80ae3308 @   18 : ab                Return 

    각 내용들은 레지스터(CPU가 내부에서 작업 처리할 때 필요한 데이터를 저장하는 고속 기억장치)에 값들을 할당하고 누산기(계산한 중간 결과 값을 저장하기 위한 레지스터)를 어떤 식으로 사용하라고 명령하는 명령문들이다. 

    이렇게 자바스크립트 코드가 실행이 되면, 처음에는 바이트 코드로 전부 변환하는 작업이 있어서 시간이 걸리지만 그 이후부터는 컴퓨터에 더 가까운 언어인 바이트 코드를 이용함으로써 컴파일 언어에 가까운 성능을 보일 수 있다.

     

    TurboFan으로 자주 사용하는 바이트코드를 컴파일

     

    TurboFan

    TurboFan은 V8 v5.9 버전 이전에 사용되었던 Crankshaft 컴파일러를 완전히 대체한 최적화 담당 컴파일러이다. TurboFan은 바이트 코드로 수시로 변환하는 과정을 최소화하기 위해 사용된다.

    V8은 런타임 중에 Profiler라는 친구에게 함수나 변수들의 호출 빈도와 같은 데이터를 모으라고 시킨다. 이렇게 모인 데이터를 이용하여 TurboFan이 자기 기준에 맞는 코드를 가져와서 최적화를 시킨다.

     

    TurboFan 최적화 기법

    우선 기본적으로 자바스크립트 엔진의 최적화 기법이라 생각해도 될 것 같다. 여러 가지가 있겠지만 그중 대표적인 기법인 Hidden Class와 Inline Caching 기법이 있다. 
    그러나 이러한 기법을 깊게 설명하기에는 V8엔진에 대한 설명과 멀어질 수 있으므로 가볍게 설명하고 넘어간다.

    1. Hidden Class

    자바스크립트는 기본적으로 클래스가 없고 대신 Prototype이라는 개념이 있다. 자바스크립트 내에서 사용되는 Number, Boolean 등과 같은 정적 타입 데이터 외 모든 데이터를 객체로 취급된다.

    function foo(a){
      this.x = a;
    }
    
    var a = new foo(1);
    
    a.y = 2;
    
    var b = new foo(3);

     

    위 코드를 보면, foo라는 객체를 생성 후, foo 객체에 동적으로 y속성을 추가한다.
    이처럼 자바스크립트 객체의 형태가 딱 정해져 있지 않고 동적으로 변할 수 있기 때문에 메모리 할당에 있어 이와 같은 동적인 변화에 대한 정보가 따로 저장이 되어야 한다. 그렇기에 자바스크립트 코드가 다른 언어들에 비해서 성능이 많이 떨어질 수밖에 없으나, Hidden Class를 이용하여 어느 정도 성능 저하를 줄일 수 있게 된다.

    자바스크립트 엔진 내부의 Hidden Class라는 개념을 이용하여 각 객체에 대한 속성 값의 포인터만 가지고, 해당되는 구조를 참조한다.
    이에 대한 더 자세한 사항은 따로 블로깅을 할 예정이다.

    2. Inline Caching

    Inline Caching은 반복문 내의 객체 접근 시 '조회' 작업을 생략하게 함으로써 성능 향상을 도모하는 기법이다. 인라인 캐싱은 Hidden Class에서 참조하는 Offset(초기 셋)을 캐싱하는 것이다. 쉽게 생각해서 참조하는 일을 최대한 없애서 성능을 향상하는 것.
    인라인 캐싱에서 두 가지 가정이 바탕에 깔려 있다.

    - 동적인 언어이지만 실제로 중간에 바뀌지 않는 것이 더 많다.
    - Loop 내에서는 변화가 없다.

    객체의 형태가 동적으로 변할 수 있는 환경이지만, 실제로는 첫 형태에서 변경되어 사용하는 객체가 많지 않다고 가정한다. 그리고 Loop 문 내에서 변할 일을 거의 없을 것이라고 생각한다.

    이 가정을 두고 캐싱하여 더 빠른 Hidden  Class 참조로 성능을 끌어올린다.
    이에 대한 내용도 향후에 따로 블로깅을 다시 할 예정이다.

    다시 TurboFan으로 돌아와서, TurboFan이 최적화시킬 코드를 어떻게 판별하는지 알아보자.

    최적화 시킬 코드 구분 방식

    그렇다면 TurboFan은 어떤 방식으로 최적화시킬 코드를 판단할까?
    아래는 V8엔진 오픈소스 내에서 함수를 최적화할지 말지를 판별하는 RunTimeProfiler의 ShouldOptimize 메소드이다.

    // v8/src/execution/rumtime-profiler.cc
    OptimizationReason RuntimeProfiler::ShouldOptimize(JSFunction function, BytecodeArray bytecode) {
      // int ticks = 이 함수가 몇번 호출되었는지
      int ticks = function.feedback_vector().profiler_ticks();
      int ticks_for_optimization =
          kProfilerTicksBeforeOptimization +
          (bytecode.length() / kBytecodeSizeAllowancePerTick);
      if (ticks >= ticks_for_optimization) {
        // 함수가 호출된 수가 임계점인 ticks_for_optimization을 넘기면 뜨거워진 것으로 판단
        return OptimizationReason::kHotAndStable;
      } else if (!any_ic_changed_ && bytecode.length() < kMaxBytecodeSizeForEarlyOpt) {
        // 이 코드가 인라인 캐싱되지 않았고 바이트 코드의 길이가 작다면 작은 함수로 판단
        return OptimizationReason::kSmallFunction;
      }
      // 해당 사항 없다면 최적화 하지 않는다.
      return OptimizationReason::kDoNotOptimize;
    }
    

     

    위 코드를 보면, if문 내 return으로 OptimizationReason이 있다. 그리고 코드 가장 마지막 return에도 OptimizationReason이 있는 것을 알 수 있다. 

    OptimizationReason::kHotAndStable은 이름 그대로 뜨겁고(자주 호출) 안정된 것이란 것(코드가 안 변함)을 알 수 있다. 매번 같은 행동을 수행하는 반복문 내에 있는 코드 같은 경우가 여기에 해당하기 쉽다.

    OptimizationReason::kSmallFunction도 이름 그대로 작은 함수이다. 즉, 인터프리팅된 바이트 코드의 길이를 보고 특정 임계점을 넘기지 않으면 작은 함수라고 판단해서 최적화를 진행한다. 함수가 작을수록 가볍고 변경되는 내용이 없다고 판단하여 안정적으로 볼 수 있기 때문이다.

    즉, 최적화될 코드는 자주 호출되면서 코드 변함이 없는 것, 또는 크기가 작은 함수로 판단하는 것을 알 수 있다.


    역시...여기까지 정리하는데 매우 어렵다.. V8 엔진 쉽지않다.

    그래도 어느정도 정리하면서 V8 엔진 구조 내에서 자바스크립트를 최적화 시키기 위한 기법들이 많이 들어있다는 것을 알 수 있었다. 특히 TurboFan에 대해서는 나중에 제대로 다시 파고들어보면 엄청 도움이 많이 될 것 같다. 

    진짜 언젠가는 이 정도 로우 레벨까지 직접 건드릴 수 있는 개발자가 되고 싶다. 향후  C++ 언어 공부를 좀 해봐야겠다.

    반응형

    댓글

Designed by Tistory.