V8 엔진은 어떻게 내 코드를 실행하는 걸까?

    V8 엔진은 어떻게 내 코드를 실행하는 걸까?


    이번 포스팅에서는 구글의 V8 엔진이 어떤 방식으로 자바스크립트를 해석하고 실행하는지 살펴 보는지에 대해 포스팅하려고 한다. V8C++로 작성되었지만 필자의 메인 언어가 C++이 아니기도 하고, 워낙 소스가 방대하기 때문에 자세한 분석까지는 아니라도 최대한 웹 상에 있는 정보들과 필자가 분석한 V8의 소스코드를 비교해가면서 살펴보려고 한다.

    V8 엔진이란?

    V8 엔진은 구글이 주도하여 C++로 작성된 고성능의 자바스크립트 & 웹 어셈블리 엔진이다. 또한 V8은 오픈 소스이기 때문에 V8 엔진 깃허브 레파지토리에서 클론받을 수 있다. 현재 Google ChromeNodeJS에서 사용되고 있으며 ECMAScriptWeb Assembly를 표준에 맞게 구현하였다.

    Kangax Table에서 확인해보면, V8을 사용하고 있는 CH(Google Chrome)Node는 거의 대부분의 ES2016+(ES7+)의 기능을 구현해놓은 반면 MicroSoft의 Chakra나 Mozila의 SpiderMonkey의 경우 붉은 색으로 표시된 부분이 꽤 존재한다는 것을 볼 수 있다.

    다른 플랫폼과의 호환과 서로 간의 리스크를 줄이기 위해, 마이크로소프트 Edge는 이제 이 변경 사항의 일부분으로 인해 V8 엔진을 사용할 것 입니다. 우리가 배워야할 것들은 아직 많지만 그래도 우리는 V8 커뮤니티의 일원이 된 것과 이 프로젝트에 기여하게 된 것이 매우 기대됩니다.

    ChakraCore team ChakraCore Github Issue

    하지만 Chakra 엔진을 사용하던 Microsoft Edge의 경우, 이제 Chromium 오픈소스 프로젝트에 동참하면서 V8을 사용할 예정이라고 한다. 음… 좋은 건지 나쁜 건지 아직은 잘 모르겠다.

    만약 V8을 빌드해서 실제로 디버깅까지 해보고 싶다면 단순히 git을 사용하여 클론 받는 것으로는 빌드를 할 수 없다. 빌드까지 해보고 싶은 분은 V8 공식 홈페이지의 Checking out the V8 source code를 읽어보자.

    meme1 Chromium이 워낙 대형 프로젝트다보니 빌드 한번 하려면 설치 과정부터 까다롭다

    필자는 예전에 한번 셋업해본 적이 있는데, 당연하게도 순순히 잘 설치되어주지는 않으니 시간 많은 주말에 시도하는 것을 추천한다. 이번에는 단순히 소스 코드만 분석할 예정이므로 필자는 git을 이용하여 직접 클론받았다.

    V8 엔진의 작동원리를 살펴보자.

    일반적으로 우리가 자바스크립트를 사용할 때 엔진의 작동 원리와 같은 로우 레벨(Low Level)의 내용까지 깊게 신경쓸 필요는 없다. 사실 개발자들이 그런 것까지 일일히 신경쓰지 말라고 엔진을 사용하는 것이기 때문이다. 그러나 정말 자바스크립트로 뽑아낼 수 있는 최적의 성능을 사용하고 싶다면 내 코드가 어떤 식으로 실행되는 지에 대한 이해는 어느 정도 있어야한다.

    그럼 먼저 간단한 그림으로 V8이 우리의 자바스크립트 소스 코드를 어떻게 해석하고 실행하는 지 살펴보자. 자세한 설명은 밑에서 다시 할 예정이므로 그냥 한번 흝고 넘어간다는 느낌으로 보면 된다.

    v8compiler pipeline JSConf EU 2017에서 발표한 Franziska Hinkelmann님의 자료

    V8은 우리의 소스 코드를 가져와서 가장 먼저 파서(Parser)에게 넘긴다. 이 친구는 소스 코드를 분석한 후 AST(Abstract Syntax Tree), 추상 구문 트리로 변환하게 된다. 그 다음에 이 AST를 그림에 나와있는 Ignition에게 넘기는데 이 친구는 자바스크립트를 바이트 코드(Bytecode)로 변환하는 인터프리터이다. 원본 소스 코드보다 컴퓨터가 해석하기 쉬운 바이트 코드로 변환함으로써 원본 코드를 다시 파싱(Parsing)해야하는 수고를 덜고 코드의 양도 줄이면서 코드 실행 때 차지하는 메모리 공간을 아끼려는 것이다.

    이후 이 바이트 코드를 실행함으로써 우리의 소스 코드가 실제로 작동하게 되고, 그 중 자주 사용되는 코드는 TurboFan으로 보내져서 Optimized Machine Code, 즉 최적화된 코드로 다시 컴파일된다. 그러다가 다시 사용이 덜 된다 싶으면 Deoptimizing 하기도 한다.

    여기서 사용되는 용어들이 굉장히 재미있는데, V8은 원래 8기통 엔진의 종류를 의미하는 단어다. 제네시스 G90이나 기아 K9같은 차에 들어간다고 한다. 그럼 Ignition은 뭐냐. 엔진에 시동걸 때 사용하는 점화기이다. 내 소스 코드가 부릉부릉 실행되는 것이다. 그러다가 너무 많이 호출되서 내 코드가 뜨거워지면 TurboFan으로 최적화해서 너무 과열되지 않게 식혀주는 그런 느낌적인 느낌…? 분명히 다 노리고 네이밍한거다.

    V8 분석 포스팅에서 컴파일 파이프라인을 설명할 때 빠지지 않던 Full-codegenCrankshaft는 어디로 갔냐 하면…

    V8 v5.9부터 처음으로 Ignition과 TurboFan은 자바스크립트 실행을 위해 전체적으로 사용됩니다. 또한 V8의 v5.9부터 V8을 잘 지탱해준 기술이었던 Full-codegen과 Crankshaft는 새로운 자바스크립트의 기능과 이러한 기능들이 요구하는 최적화 기능을 더 이상 따라갈 수 없기 때문에 V8에서 더 이상 사용되지 않습니다. 우리는 이것들을 곧 완전히 제거할 계획이며, 이는 V8이 앞으로 훨씬 더 단순하고 유지 보수 가능한 아키텍처를 갖게 된다는 것을 의미합니다.

    V8 team Launching Ignition and TurboFan

    bye 네. V8의 v5.9부터 세대 교체 당했습니다.

    v5.9 이전까지는 Full-codegenCrankshaft도 공존하고 있었지만 이건 V8 팀이 원했던 것이 아니라 초창기의 IgnitionTurboFan의 성능이 생각만큼 잘 나와주지 않았던 것도 있고 Optimizing된 코드를 다시 Deoptimizing할 때 바이트 코드로 바로 변환할 수 없던 이슈들이 있어서 어쩔 수 없이 중간에 Full-codegenCrankshaft를 살려둔 것이다.

    V8 팀의 원래 목적은 처음부터 IgnitionTurboFan만 사용하여 바이트 코드 <-> 최적화된 코드 사이를 왔다갔다 하는 것이었다.


    이 영상은 BlinkOn 2016에서 Chrome Mobile Performance London Team팀의 Ross McIlroyIgnition을 소개하는 영상인데, 9:47 ~ 11:14 구간에서 레거시 코드인 Full-codegenCrankshaft를 삭제하지 못한 슬픈 사정을 설명해준다. 본인도 말하면서 웃긴듯.

    그럼 이제 V8이 자바스크립트를 어떤 식으로 파싱하고 실행시키는 지 간략하게 한번 알아보자.

    Parsing, 코드의 의미 파악하기

    파싱(Parsing)이란, 소스코드를 불러온 후 AST(Abstract Syntax Tree), 추상 구문 트리로 변환하는 과정이다. AST는 컴파일러에서 널리 사용되는 자료 구조인데, 우리가 일반적으로 작성한 소스 코드를 컴퓨터가 알아먹기 쉽게 구조화한다고 생각하면 된다.

    예를 들어, 자바스크립트로 자바스크립트를 파싱한다고 하면 이런 느낌이다.

    function hello (name) {
      return 'Hello,' + name;
    }
    
    // 위 코드는 대략 이렇게 구조화 할 수 있다.
    
    {
      type: 'FunctionDeclaration',
      name: 'hello'
      arguments: [
        {
          type: 'Variable',
          name: 'name'
        }
      ]
      // ...
    }

    이렇게 놓고 보니 생각보다 심플하다. 다만 이것은 예시 중 하나일 뿐이고 컴파일러는 for, if, a = 1 + 2, function () {}과 같은 문법도 모두 해석하여 파싱해야 하다보니 파서(Parser)의 내부는 생각보다 거대하다. 당장 V8parser.cc 파일도 3000줄이 넘는다.

    어쨌든 파싱이라는 개념 자체는 컴퓨터가 분석하기 쉬운 형태인 추상 구문 트리로 변경하는 작업이라는 것만 기억하자. V8 엔진은 방금 예시에서 자바스크립트로 표현했던 것을 C++을 사용하여 그대로 할 뿐이다.

    그럼 V8 엔진의 파싱 코드 중 1 + 2와 같이 리터럴로 선언된 수식을 담당하는 메소드를 한번 살펴보자.

    // v8/src/parsing/parser.cc
    bool Parser::ShortcutNumericLiteralBinaryExpression(Expression** x, Expression* y, Token::Value op, int pos) {
      if ((*x)->IsNumberLiteral() && y->IsNumberLiteral()) {
        double x_val = (*x)->AsLiteral()->AsNumber();
        double y_val = y->AsLiteral()->AsNumber();
        switch (op) {
          case Token::ADD:
            *x = factory()->NewNumberLiteral(x_val + y_val, pos);
            return true;
          case Token::SUB:
            *x = factory()->NewNumberLiteral(x_val - y_val, pos);
            return true;
          // ...
          default:
            break;
        }
      }
      return false;
    }

    이 코드는 V8 엔진의 parser.cc에 선언된 Parser 클래스의 ShortcutNumericLiteralBinaryExpression(이름이 더럽게 길다…) 스태틱 메소드이다.

    인자를 한번 살펴보면 Expression 클래스의 객체인 xy표현식에 사용된 값들 의미한다. op는 이 표현식이 의미하는 것이 x + y인지 x - y인지와 같은 실제 구문 내용과 그 타입을 의미하고 pos는 전체 소스 코드 중 현재 파싱하는 소스 코드의 위치를 의미한다.

    이 메소드는 위에서 설명했듯이 1 + 2와 같은 소스 코드를 만났을 경우 호출되며, 인자로 받은 표현을 Token::ADDToken::SUB와 같은 조건으로 검사하여 조건에 맞게 파싱하고 있는 모습을 볼 수 있다. 여기서 말하는 토큰은 소스 코드를 자바스크립트의 문법 규칙에 따라 어휘 분석하여 나온 문자열 조각들이다.

    // 토큰은 대충 이런 느낌
    ['const', 'a', '=', '1', '+', '2'];

    이후 알맞게 계산되어 나온 값을 AstNodeFactory 클래스의 NewNumberLiteral 스태틱 메소드를 사용하여 추상 구문 트리의 노드로 만드는 모습을 볼 수 있다.

    // v8/src/ast/ast.cc
    Literal* AstNodeFactory::NewNumberLiteral(double number, int pos) {
      int int_value;
      if (DoubleToSmiInteger(number, &int_value)) {
        return NewSmiLiteral(int_value, pos);
      }
      return new (zone_) Literal(number, pos);
    }

    V8은 이 과정에서 변수, 함수, 조건문과 같은 코드의 의미를 파악하며, 우리에게 익숙한 자바스크립트의 스코프 또한 이 과정에서 설정된다. 이 중 변수 선언에 관한 자세한 내용은 JavaScript의 let과 const, 그리고 TDZ를 참고하자.

    Ignition으로 바이트 코드(Bytecode) 생성하기


    바이트 코드(Bytecode)는 고오급 언어로 작성된 소스 코드를 가상머신이 한결 편하게 이해할 수 있도록 중간 코드로 한번 컴파일 한 것을 의미한다. V8에서는 Ignition이 이 역할을 수행하고 있다.

    Ignition이란?

    Ignition은 기존의 Full-codegen을 완벽히 대체하는 인터프리터이다. 기존에 사용하고 있던 Full-codegen은 전체 소스 코드를 한번에 컴파일했는데, 위에서 설명했듯 V8팀은 기존의 Full-codegen이 모든 소스 코드를 한번에 컴파일할때 메모리 점유를 굉장히 많이 한다는 사실을 인지하고 있었다.

    또 자바스크립트는 C++과 같은 정적 타이핑 언어가 아닌 동적 타이핑 언어라서 소스 코드가 실행되기 전에는 알 수 없는 값들이 너무 많았기 때문에 이런 접근 방법으로는 최적화를 하기도 힘들었다고 한다.

    그래서 Ignition을 개발할 때는 모든 소스를 한번에 해석하는 컴파일 방식이 아닌 코드 한줄 한줄이 실행될 때마다 해석하는 인터프리트 방식을 채택하여 다음 세가지 이점을 가져가고자 하였다.

    1. 메모리 사용량 감소. 자바스크립트 코드에서 기계어로 컴파일하는 것보다 바이트 코드로 컴파일하는 것이 더 편하다.
    2. 파싱 시 오버헤드 감소. 바이트 코드는 간결하기 때문에 다시 파싱하기도 편하다.
    3. 컴파일 파이프 라인의 복잡성 감소. Optimizing이든 Deoptimizing이든 바이트 코드 하나만 생각하면 되기 때문에 편하다.

    Ross McIlroy Ignition - an interpreter for V8

    이렇게 Ignition은 코드가 한줄한줄 실행될 때마다 코드를 바이트 코드로 바꿔주는 친구라는 정도만 알아두면 된다. 그럼 바이트 코드라는 게 도대체 어떻게 생겨먹었길래 컴퓨터가 해석하기 더 편하다는 걸까? 아까 위에서 사용했던 hello 함수를 가져와서 한번 어떤 바이트 코드가 생성되는 지 살펴보자.

    바이트 코드를 직접 확인하기

    function hello(name) {
      return 'Hello,' + name;
    }
    console.log(hello('Evan')); // 함수를 호출해서 코드를 사용하지 않는다면 바이트 코드로 인터프리팅하지 않는다.

    만약 NodeJS v8.3+을 사용하고 있다면 --print-bytecode 옵션을 주는 것만으로 내 소스 코드가 바이트 코드로 어떻게 인터프리팅되었는지 확인할 수 있다. 혹은 V8이 제공하고 있는 D8 디버깅 도구를 사용해도 되는데 이 친구는 V8을 빌드해야 사용할 수 있고, 위에서 설명했듯이 빌드 환경 세팅이 순탄하지는 않기 때문에 필자는 그냥 --print-bytecode를 사용했다.

    $ node --print-bytecode add.js
    ...
    [generated bytecode for function: hello]
    Parameter count 2
    Frame size 8
       15 E> 0x2ac4000d47b2 @    0 : a0                StackCheck
       30 S> 0x2ac4000d47b3 @    1 : 12 00             LdaConstant [0]
             0x2ac4000d47b5 @    3 : 26 fb             Star r0
             0x2ac4000d47b7 @    5 : 25 02             Ldar a0
       46 E> 0x2ac4000d47b9 @    7 : 32 fb 00          Add r0, [0]
       53 S> 0x2ac4000d47bc @   10 : a4                Return
    ...

    필자가 선언한 hello 함수가 바이트 코드로 변환된 모습이다. 어라? 근데 필자는 분명히 name 인자 한개만 사용했는데 Parameter count2라고 찍혀있다. 이 중 하나는 암시적 리시버인 this이다. 함수 내부에서 this를 사용하면 함수 자신을 가리킬 수 있는 그 this 맞다.

    이제 그 밑으로는 레지스터에 값들을 할당하는 모습을 볼 수 있는데, 간단하게만 설명하고 넘어가겠다.

    혹시 모르실 분들을 위해 간단히 설명하자면, 레지스터(Register)는 CPU가 가지고 있는 고속 메모리이고 누산기(Accumulator)는 계산한 중간 결과를 저장하기 위한 레지스터이다.


    1. StackCheck: 스택 포인터의 상한값을 확인한 것이다. 이때 스택이 임계 값을 넘어가면 Stack Overflow가 발생하기 때문에 함수 실행을 중단해버린다.
    2. LdaConstant [0]: LdLoad의 약자이다. 말 그대로 어떠한 상수를 누산기(Accumulator)에 불러온 것이다. 이 상수는 Hello,이다.
    3. Star r0: 누산기에 들어있는 값을 레지스터 r0번으로 이동시킨다. r0은 지역 변수를 위한 레지스터이다.
    4. Ldar a0: 누산기에 레지스터 a0번에 있는 값을 담는다. 이 경우 a0 레지스터의 값은 인자 name이다.
    5. Add r0, [0]: r0에 있는 Hello,0을 더하고 누산기에 저장한다. 이때 상수 0은 코드가 실행될 때 인자 name으로 매핑된다.
    6. Return: 누산기에 있는 값을 반환한다.

    hello 함수는 평소에 자바스크립트를 사용할 때는 아무 생각 없이 선언할 수 있는 정도의 가벼운 함수였지만 내부적으로는 6단계를 거쳐서 값을 반환하고 있었다.

    slow 이렇게 일 많이 하니까 가끔 느리다고 너무 뭐라 하지 맙시다

    바이트 코드는 직접 CPU 내의 레지스터와 누산기를 어떤 식으로 사용하라고 명령하는 명령문이나 마찬가지기 때문에 사람 입장에서는 머리 터지겠지만 컴퓨터 입장에서는 한결 이해하기가 편한 방식이다.

    V8 엔진은 우리가 작성한 자바스크립트 코드를 내부적으로는 이런 모습의 바이트 코드로 전부 변환해놓기 때문에 코드 라인이 처음 실행될 때는 조금 시간이 걸리겠지만 그 이후부터는 거의 컴파일 언어에 가까운 성능을 보일 수 있다.

    TurboFan으로 뜨거워진 코드 식히기

    v8-turbofan

    TurboFanV8v5.9부터 기존에 사용하던 Crankshaft 컴파일러를 완전히 대체한 최적화 담당 컴파일러이다.

    그럼 Crankshaft는 왜 사라졌을까? 처음 V8이 세상에 나온 이후로 새로운 컴퓨터 아키텍처도 나오고 자바스크립트도 계속 발전했기 때문에 V8도 계속해서 이런 것들을 지원해줘야 했다. V8 팀은 이런 새로운 사양에 맞춰서 V8을 계속 개량했어야 했는데, 어떻게든 계속 해서 땜빵치다가 결국 Crankshaft의 구조로는 지속적인 확장이 어렵다고 판단했고, 여러 레이어로 계층화되어 좀 더 유연하게 확장에 용이하도록 설계한 TurboFan을 만들어서 사용하고 있다.

    legacy Crankshaft와 TurboFan을 동시에 굴릴 때 왠지 이런 느낌이었을 것 같다...

    7개의 아키텍처를 지원할 때 Crankshaft로는 13,000 ~ 16,000라인의 코드로 작성했던 게 TurboFan에서는 3,000라인 미만의 코드로 커버가 가능하다고 한다.

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

    최적화 기법으로는 히든 클래스(Hidden Class)인라인 캐싱(Inline Caching) 등 여러가지 기법을 사용하지만 이 내용은 추후 다른 포스팅에서 더 자세히 다루도록 하겠다. 간단히만 설명하자면 히든 클래스는 비슷한 놈들끼리 분류해놓고 가져다 쓰는 것, 인라인 캐싱은 자주 사용되는 코드가 만약 hello()와 같은 함수의 호출부라면 이걸 function hello () { ... }와 같이 함수의 내용으로 바꿔버리는 것이다. 말 그대로 캐싱(Caching)이다.

    어떤 조건으로 최적화 하는 걸까?

    그렇다면 TurboFan은 정확히 어떤 조건으로 최적화될 코드를 구분하는 걸까? 우선 V8 소스 내에서 함수를 최적화할지 말지를 판별하는 RuntimeProfilerShouldOptimize 메소드를 예시로 한번 살펴보자.

    // 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;
    }

    생각보다 조건이 별로 없어서 당황했다. 물론 이 메소드에서 판별 하지않는 좀 더 디테일한 조건들도 존재하지만 일단 큰 틀은 이 메소드가 거의 다 가지고 있다.

    kHotAndStable은 코드가 뜨겁고 안정적이라는 것인데, 쉽게 말하면 자주 호출되고(뜨겁고) 코드가 안 변함(안정적)이라는 것이다. 매번 같은 행동을 수행하는 반복문 내에 있는 코드 같은 경우가 여기에 해당하기 쉽다.

    kSmallFunction은 말 그대로 인터프리팅된 바이트 코드의 길이를 보고 특정 임계점을 넘기지 않으면 작은 함수라고 판단해서 최적화를 진행하는 것이다. 작고 단순한 함수는 크고 복잡한 함수보다 동작이 매우 추상적이거나 제한적인 확률이 높기 때문에 안정적이라고 볼 수 있다.

    TurboFan이 일하는 모습 훔쳐보기

    그럼 간단한 코드 예제를 통해 최적화가 어떤 방식으로 진행되는 지 확인해보자. 필자는 작은 함수 하나를 선언하고 반복문을 통해서 계속 호출해줄 것이다.

    필자의 목표는 선언한 함수가 ticks >= ticks_for_optimization 조건에 걸려서 kHotAndStable 상태가 되는 것이다. 필자 생각으로는 대충 아무 함수나 선언해서 같은 타입의 인자를 사용하고 반복적으로 파바박! 호출하면 ShouldOptimize 메소드의 ticks >= ticks_for_optimization 조건에 걸릴 것이고, kHotAndStable 상태가 되어 최적화가 진행될 거라고 생각한다.

    NodeJS를 실행할 때 --trace-opt 옵션을 주면 런타임 때 코드가 최적화되는 것을 확인해볼 수 있다.

    function sample(a, b, c) {
      const d = c - 100;
      return a + d * b;
    }
    
    for (let i = 0; i < 100000; i++) {
      sample(i, 2, 100);
    }
    $ node --trace-opt test.js
    
    [marking 0x010e66b69c09 <JSFunction (sfi = 0x10eacdd4279)> for optimized recompilation, reason: small function, ICs with typeinfo: 3/3 (100%), generic ICs: 0/3 (0%)]
    [marking 0x010e66b6a001 <JSFunction sample (sfi = 0x10eacdd4371)> for optimized recompilation, reason: small function, ICs with typeinfo: 3/3 (100%), generic ICs: 0/3 (0%)]
    [compiling method 0x010e66b6a001 <JSFunction sample (sfi = 0x10eacdd4371)> using TurboFan]
    [compiling method 0x010e66b69c09 <JSFunction (sfi = 0x10eacdd4279)> using TurboFan OSR]
    [optimizing 0x010e66b69c09 <JSFunction (sfi = 0x10eacdd4279)> - took 0.132, 0.453, 0.027 ms]
    [optimizing 0x010e66b6a001 <JSFunction sample (sfi = 0x10eacdd4371)> - took 0.850, 0.549, 0.012 ms]
    [completed optimizing 0x010e66b6a001 <JSFunction sample (sfi = 0x10eacdd4371)>]

    오 최적화가 되긴 했다. ICs with typeinfo: 3/3 (100%)라고 적혀있는걸 보니 인라인 캐싱을 했나보다. 근데 최적화를 한 이유를 보니 small function이라고 적혀있다. 필자가 원했던 조건은 kHotAndStable으로 빠지는 것이었기 때문에 코드를 조금 바꿔서 다시 해봐야겠다. 함수가 너무 간단하니까 TurboFan이 필자의 함수를 만만하게 봤나보다.

    function sample() {
      if (!arguments) {
        throw new Error('인자를 주시오');
      }
    
      const array = Array.from(arguments);
      return array
        .map(el => el * el)
        .filter(el => el < 20)
        .reverse();
    }
    
    for (let i = 0; i < 100000; ++i) {
      sample(1, 2, 3, 4, 5);
    }

    그냥 아무 의미 없지만 적당히 더 복잡하게 만들어 보았다. sample 함수는 그냥 인자를 받아서 변형하고 걸러내고 순서를 뒤집어서 반환하는 역할을 수행한다. 여기서 Turbofan이 감시하고 있는 최적화 대상은 sample, map, filter, reverse, Array.from 같은 친구들이 될 것이다. 감시 대상이 많으므로 로그도 어마무시하게 나오기 때문에 TurboFan이 함수를 최적화 대상으로 marking 하는 부분만 가져오겠다.

    $ node --trace-opt test.js
    
    [marking 0x1a368a90cc51 <JSFunction (sfi = 0x1a36218d4279)> for optimized recompilation, reason: small function, ICs with typeinfo: 3/3 (100%), generic ICs: 0/3 (0%)]
    
    [marking 0x1a36bcfa9611 <JSFunction array.map.el (sfi = 0x1a36218d46f9)> for optimized recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
    
    [marking 0x1a36bcfa96a1 <JSFunction array.map.filter.el (sfi = 0x1a36218d4761)> for optimized recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
    
    [marking 0x1a368a90cc11 <JSFunction sample (sfi = 0x1a36218d4371)> for optimized recompilation, reason: hot and stable, ICs with typeinfo: 10/11 (90%), generic ICs: 0/11 (0%)]
    
    [marking 0x1a36e4785c01 <JSFunction UseSparseVariant (sfi = 0x1a36660866d9)> for optimized recompilation, reason: small function, ICs with typeinfo: 1/5 (20%), generic ICs: 0/5 (0%)]
    
    [marking 0x1a36e4786fc1 <JSFunction reverse (sfi = 0x1a3666086f21)> for optimized recompilation, reason: hot and stable, ICs with typeinfo: 4/5 (80%), generic ICs: 0/5 (0%)]

    오 드디어 reason: hot and stable이 나왔다. <JSFunction 함수 이름>과 같은 포맷으로 함수 정보가 함께 출력되기 때문에 hot and stable의 이유로 최적화 대상을 찍힌 친구가 samplereverse 함수라는 것을 알 수 있다. 이와 같이 TurboFan은 한가지 데이터가 아니라 여러가지 데이터를 프로파일링하며 이 코드를 최적화할 것인지 구분한다.

    마치며

    최근 잘 짜여진 자바스크립트는 C++에 근사하는 성능을 낼 수도 있다고 한다. 처음 이 이야기를 들었을 때는 “인터프리터 언어가 어떻게 컴파일 언어 성능을 내?”라고 생각했었지만 V8의 작동 원리를 살펴보니 생각보다 많은 최적화 기법이 들어가 있어서 놀랐다.

    그리고 이렇게 자바스크립트 엔진을 하나하나 뜯어보면서 내가 좋아하는 언어에 대한 이해도를 높히는 과정은 굉장히 재밌었다.(학교 다닐 때 C++ 좀 많이 써볼걸…)

    사실 V8 엔진 내부에는 “오…개쩌는데…?” 라는 말이 나올만한 최적화 기법이 많이 적용되어 있기 때문에 이것저것 다 소개하고 싶었지만, 포스팅을 쓰다보니 뭔가 내용이 길어지면서 점점 산으로 가고… 그래서 이번에는 전체적인 흐름을 설명하는 선까지만 하고 다음 포스팅때 IgnitionTurboFan의 작동에 대해서 좀 더 디테일하게 설명해보도록 하겠다. 특히 TurboFan의 동작 흐름을 아는 것은 내 자바스크립트 코드를 최적화할 수 있는 지름길이기 때문에 알아두면 요긴하게 써먹을 수 있을 것 같다.

    이상으로 V8 엔진은 어떻게 내 코드를 실행하는 걸까? 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.