자바스크립트 최적화 (ft.JIT)

Pyotato·2024년 4월 22일
1

🤔 최근 성능 최적화에 대해 관심이 생기고 나서 lighthouse 등을 통해 인라인 css를 제거하거나, 이미지 preload 방식을 도입해서 LCP 줄이기, 혹은 리액트 useMemo 등을 통해 리랜더링을 최소화하는 방안 등, 자바스크립트 언어 본질적인 측면 외의 접근을 많이 했던 거 같았습니다.
기본이 제일 중요한 만큼, 코드를 이루고 있는 자바스크립트 자체의 최적화 방안들을 공부하며 정리해봤습니다.
🗣️ 내용 전달의 간결함을 위해 이후 내용은 비격식체로 작성했습니다.

1. JIT 컴파일러

V8 엔진이란?

  • 구글에서 만든 오픈 소스 자바스크립트 엔진으로써, C++로 작성되었으며, 구글 크롬, 크롬 웹브라우저와 노드js등을 지원한다. 프로그램을 실행하기 위한 환경과 상호작용하고 바이트코드를 생성하는 역할을 한다.

이 V8엔진이 다른 엔진(mozilla Firefox의 spiderMonkey, apple Safari의 JavaScriptCore, 마이크로소프트의 Chakra)들과 다른 점이 JIT컴파일러가 있다는 점이다. JIT 컴파일러는 런타임에 자바스크립트를 기계어로 컴파일하고 중간코드(intermediate code)를 생성하지 않는다.

위의 그림에서 v8엔진은 핵심 부분 2가지로 나눌 수 있다.

  1. Ignition Interpreter
  • 코드를 파싱하여 바이트코드로 인터프레트 담당, 파서에 의해 생성된 abstract syntax tree (AST)를 바이트 코드로 생성.
  • 컴파일러와 달리 필요한 부분만 컴파일 하는 인터프레터의 속성덕분에 메모리 사용이 감소한다.
  1. Turbofan Compiler
  • 코드 첫 실행시만 담당하는 Ignition Interpreter와 달리, 생성된 바이트 코드를 Turbofan이라는 컴파일러가 사용한다.
  • turbofan은 코드 실행 중 얻는 데이터 값을 토대로 코드 최적화를 한다. 즉, 더 최적화된 버전으로 코드를 재컴파일한다.

🧐 V8이 자바스크립트 최적화에 쓰이지만 C++로 만들어졌고, 멀티쓰레드를 사용해 모든 작업을 동시에 다룬다.

JIT 동작 원리

  • 프로그램을 실행할 때 인터프리터를 사용해 일부 코드를 실행면서 반복되는 코드를 컴파일하여 기계어로 변환한다.
  • 낱말 분석 과정을 통해 코드를 토큰화한다 ➡️ parser에서 분해된 토큰을 바탕으로Abstract syntax tree를 생성 ➡️ 인터프리터를 사용해 AST를 기반으로 코드 실행 ➡️ 인터프리터는 실행과정에서 프로파일링 정보 수집 (함수 호출 빈도, 변수 타입 등) ➡️ 프로파일링 정보를 기반으로 JIT 컴파일러가 중간 표현 (IR, Intermediate Representation) 생성 ➡️ turbofan에서 IR그래프를 분석해 최적화 시도 (인라인 캐싱, 히든 클래스 등의 최적화 기술 적용) ➡️ 최적화된 IR 코드가 기계어로 컴파일 & 메모리에 로드 ➡️ 기계 코드로 실행

Abstract syntax tree

  • Abstract syntax tree는 컴파일러를 위한 소스코드의 추상화된 구조를 만들기 위해 쓰인다.
  • 이는 자바스크립트나 v8에 국한된 것이 아닌, 거의 모든 프로그래밍 언어들이 고수준 언어의 코드를 저수준언어로 변환시키는데 쓰인다.
  • AST로 코드를 변환하면, 주석과 같이 불필요한 요소는 무시하고 변수의 타입, 위치, 문(statement)의 순서 등의 필요한 디테일을 포함한다.

예시

// 함수 선언
function addition(x, y){
	var answer = x + y;
    console.log(answer);
}
// 함수 호출
addition(10, 20);

esprima를 통해 해당 코드를 파싱하면 아래와 같은 결과가 나온다.

  • syntax
{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": {
        "type": "Identifier",
        "name": "addition"
      },
      "params": [
        {
          "type": "Identifier",
          "name": "x"
        },
        {
          "type": "Identifier",
          "name": "y"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "body": [
          {
            "type": "VariableDeclaration",
            "declarations": [
              {
                "type": "VariableDeclarator",
                "id": {
                  "type": "Identifier",
                  "name": "answer"
                },
                "init": {
                  "type": "BinaryExpression",
                  "operator": "+",
                  "left": {
                    "type": "Identifier",
                    "name": "x"
                  },
                  "right": {
                    "type": "Identifier",
                    "name": "y"
                  }
                }
              }
            ],
            "kind": "var"
          },
          {
            "type": "ExpressionStatement",
            "expression": {
              "type": "CallExpression",
              "callee": {
                "type": "MemberExpression",
                "computed": false,
                "object": {
                  "type": "Identifier",
                  "name": "console"
                },
                "property": {
                  "type": "Identifier",
                  "name": "log"
                }
              },
              "arguments": [
                {
                  "type": "Identifier",
                  "name": "answer"
                }
              ]
            }
          }
        ]
      },
      "generator": false,
      "expression": false,
      "async": false
    },
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "CallExpression",
        "callee": {
          "type": "Identifier",
          "name": "addition"
        },
        "arguments": [
          {
            "type": "Literal",
            "value": 10,
            "raw": "10"
          },
          {
            "type": "Literal",
            "value": 20,
            "raw": "20"
          }
        ]
      }
    }
  ],
  "sourceType": "script"
}

AST는 각 줄의 코드에서 키와 값 쌍을 정의한다.
initial type identifier 는 AST가 프로그램에 일부이며, 객체배열로된 body 내에 코드의 각 행들이 정의된다.

함수 선언, 변수 선언, 이름, 타입 등이 줄마다 관리되며, 주석은 무시된다.

히든 클래스

  • 자바스크립트는 동적타이핑을 하는 언어다. 즉, 객체의 속성을 자유롭게 변경할 수 있고, 자바스크립트에서 암묵적 타이핑이 진행된다.

    하지만, 이렇게 객체의 속성을 자주 변경하면 동적 검색이 더욱 요구되며, 자바스크립트의 성능에는 악영향을 미친다.

히든 클래스 작동 방식

  • 새로운 객체를 생성하면 v8엔진은 새로운 히든 클래스를 생성한다.
  • 이 객체에 새로운 속성을 추가한다면 v8엔진은 이전 클래스의 속성을 포함한 새로운 히든 클래스를 만든다.

예시 1

  • 빈객체를 생성해보자.
const userObject = {};
  • v8은 이에 대응하는 오프셋이 없는 히든 클래스인C01을 생성한다.

  • 새로운 속성을 추가한다.
userObject.name = “Chameera”
  • v8은 새로운 히든 클래스인 C02를 생성한다.
  • C02는 이전의 히든 클래스인 C01의 모든 속성을 상속받고, offset 0 에 name 속성을 배정받는다.

이를 통해 컴파일러가 객체 name을 찾을 때, v8은 바로 C01을 가르킬 것이다.

만약 이 객체에 또 다른 속성을 추가한다면 동일하게 동작한다.
즉, 새로운 히든 클래스가 생성되고, 이 히든 클래스는 전의 속성과 현재의 속성을 모두 지니게 된다.

userObject.blog = “blogURL”

히든 클래스의 개념은 검색을 용이하게 할 뿐만 아니라, 비슷한 객체가 생성되거나 변경되었을 경우 재사용 가능하도록 한다.

  • 빈 객체를 생성하면 v8엔진은 새로운 히든 클래스가 아닌, C01 클래스를 가리킨다.
const articleObject = {};

하지만 articleName라는 속성을 추가해 articleObject을 변경한다면, C02은 name이라는 속성만 지녔기 때문에 v8은 더 이상이전에 생성했던 클래스인 C02를 사용하지 못한다.

인라인 캐싱

  • 호출 지점에서 이전의 메소드 검색의 결과를 기억해서 런타임 메소드 바인딩의 성능을 높인다.
  • v8엔진은 메소드 호출에 파라미터로 전달된 객체 타입의 캐시를 유지하고 이 정보를 앞으로 이용해 앞으로 파라미터로 넘어올 객체의 타입에 대해 가정을 한다.

예시

const p1 = {firstname: 'pi', lastname: 'ca', species: 'pokemon'};
const p2 = {firstname: 'pica', lastname: 'chu', skill: 'thunderbolt'};
const p3 = {firstname: 'jiwoo', lastname: 'park', job: 'trainer'};
const p4 = {firstname: 'jiwon', lastname: 'park', hobby: 'pokemon training'};
const p5 = {firstname: 'picachu', lastname: 'park', nickname: 'pokemon enthusiast'};

// 사람의 이름을 출력하는 함수
const getFullName = (user) => `${user.lastname} ${user.firstname}`;

const people = [p1, p2, p3, p4, p5]; 

console.time("최적화 되지 않은 코드");
// 최적화되지 않은 코드 
for (let i = 0; i < 500_000_000; i++) {
  getFullName(people[i % 5]);
}
console.timeEnd("최적화 되지 않은 코드");


const p1 = {firstname: 'pi', lastname: 'ca'};
const p2 = {firstname: 'pica', lastname: 'chu'};
const p3 = {firstname: 'jiwoo', lastname: 'park'};
const p4 = {firstname: 'jiwon', lastname: 'park'};
const p5 = {firstname: 'picachu', lastname: 'park'};


const people2 = [peop1, peop2, peop3, peop4, peop5];

// 최적화된 코드 
console.time("최적화된 코드");
for (let i = 0; i < 500_000_000; i++) {
  getFullName(people2[i % 5]);
}
console.timeEnd("최적화된 코드");

위의 예시에서 두 코드 모두 for문을 5억번 돌리며 이름을 출력해주는 함수다.
차이점은 파라미터로 받은 객체의 구조다.
인라인 캐시는 최적화 과정에서 객체의 속성에 접근하는 부분에 실제 메모리 주소를 할당하여 lookup 과정을 생략한다.
따라서 같은 객체의 구조에 접근하면 객체를 구분하지 않고 offset을 적용하여 최적화에 용이하다.
즉, 두번째 파라미터의 객체는 lastname과 firstname 모두 속성의 위치를 offset으로 저장하여 static하게 접근하기 때문에 최적화가 되었지만, 첫번째 파라미터는 속성이 제각각이며, 속성의 위치를 offset으로 저장하고 접근이 불가능하여 캐싱이 불가능하고 최적화가 되지 않았다.

결국 성능 최적화의 핵심은 캐싱과 관련되어 있다. 이 점을 고려해보면 loop 최적화를 통해서도 컴파일러 성능을 향상시킬 수 있습니다.

루프 최적화

  • 루프 내부에서 지시사항의 개수를 줄이면 루프 밖의 코드가 증가하더라도 속도가 개선된다.

1. Code Motion (Frequency Reduction)

빈도 감소(Frequency Reduction)는 루프 내에 코드의 양을 줄이는 것이다. 프로그램의 결과를 바꾸지 않는 선에서 문이나 식에서 루프를 꺼내는 방식으로 구현.

console.time("최적화 되지 않은 코드");
function nonOp (x){
  let i=0;
	while(i<1000){
    	let a = Math.sin(x)/Math.cos(x)+i;
      	i++;
    }
}
nonOp(1);
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 0.233154296875 
console.time("최적화 코드");
function op (x){
  let i=0;
  let t = Math.sin(x)/Math.cos(x);
	while(i<1000){
    	let a = t+i;
      	i++;
    }
}
op(1);
console.timeEnd("최적화 코드"); //최적화 코드: 0.10791015625 ms
  • 해당 코드를 실행하면 코드에 영향을 주지않는 Math.sin(x)/Math.cos(x)를 루프 밖으로 뺏을 경우 실행 속도가 0.23 ➡️ 0.10 반 이하로 줄어들었다.

2.Induction Variable Elimination

루프를 돌 때마다 특정 변수의 값이 변한다면 그 변수는 induction variable라고 부른다.
루프를 돌 때마다 값이 특정 상수값에 의해 증감한다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  const a = [1,2,3,4,5,6,7,72,8,9,15,16,22,30];
  let i=1;
  let x=i;
  let y = a[x];
  while(y<15 && x<a.length){
  	i++;
    x=3*i;
  } 
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 0.06396484375 ms
console.time("최적화 코드");
function op (){
  const a = [1,2,3,4,5,6,7,72,8,9,15,16,22,30];
  let i=1;
  let x=i;
  let y = a[x];
  while(y<15 && x<a.length){
  	i++;
    x+=4;
  } 
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.0380859375 ms

위의 예시에서 변수 i와 x는 lock되어있다. 즉, i가 1증가하면 x는 3증가된다. 따라서 i와 x는 induction variable이다.

Strength Reduction

Strength Reduction은 고비용의 연산을 저비용의 연산으로 대신하는 것이다.
곱셈이 덧셈보다 비용이 더 드는 연산이다. 따라서, 루프 내에서 곱셈을 덧셈으로 변경해줄 수 있다.

console.time("최적화 되지 않은 코드");
let x = 0;
let y;
function nonOp (){
  while(x<100000){
    y = 3*x+1;
    x+=2;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 1.031982421875 ms
console.time("최적화 코드");
let x = 0;
let t = 3*x+1;
function op (){
  while(x<100000){
    x+=2;
    t+=6;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.943115234375 ms

4. Loop Invariant Method

Loop Invariant Method에서는 같은 연산을 루프 내에서 반복하게 되면 오버헤드가 발생하기 때문에루프 안에서 식의 연산이 되지 않도록 하는 것이다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let x = 18;
  let y = 6; 
  let t;
  for(let i=0; i<10;i++){
    t = i+(x/y);
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 0.06787109375 ms

console.time("최적화 코드");
function op (){
  let x = 18;
  let y = 6; 
  let t;
  let s = x/y;
  for(let i=0; i<10;i++){
    t = i+s;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드:  0.058837890625 ms

5. Loop Unrolling

반복문을 제거하거나 줄이는 방식이다. Loop unrolling은 프로그램의 효율성을 높이고 반복문으로 인한 오버헤드를 줄일 수 있다. 루프문들이 서로에 의존적이지 않다면 평행으로 실행가능하다.
다만, 프로그램 코드 크기가 늘어나거나 단순하거나 작은 코드가 아닌 여러 브랜치의 반복문은 재귀보다 느릴 수 있다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  for(let i=0; i<5;i++){
    console.log('pika pika!\n')
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 0.376220703125 ms

console.time("최적화 코드");
function op (){
  console.log('pika pika!\n');
  console.log('pika pika!\n');
  console.log('pika pika!\n');
  console.log('pika pika!\n');
  console.log('pika pika!\n');
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드:  0.3369140625 ms

6. Loop Jamming

두 개 이상의 루프를 하나의 루프로 합치는 것. 여러 루프를 컴파일하는데 시간을 줄일 수 있다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let a,b;
  for(let i=0; i<5;i++){
    a = i + 5;
  }
  for(let i=0; i<5;i++){
    b = i + 10;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 0.06201171875 ms

console.time("최적화 코드");
function op (){
 let a,b;
  for(let i=0; i<5;i++){
    a = i + 5;
    b = i + 10;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드:  0.05517578125 ms

7. Loop Fission

Loop Fission은 참조 지역성을 개선한다. 하나의 루프가 여러 개의 루프로 나뉘어 큰 영역의 반복문의 body를 축소하여 성능을 개선하는 방법이다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let a = Array(100);
  let b = Array(100);
  for(let i=0; i<100;i++){
    a[i] = 1;
    b[i] = 2;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드: 0.074951171875 ms

console.time("최적화 코드");
function op (){
  let a = Array(100);
  let b = Array(100);
  for(let i=0; i<100;i++){
    a[i] = 1;
  }
  for(let i=0; i<100;i++){
    b[i] = 2;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.10498046875 ms

8. Loop Interchange

  • Loop fusion 와 마찬가지로 참조의 지역성(locality)을 개선하는 방식이다. 내부의 반복문이 외부의 반복문과 교환되는 방식이다.
console.time("최적화 되지 않은 코드");
function nonOp (){
  let a = Array.from({length:10},()=>Array(10))
  for(let x=0; x<10; x++){
  	for(let y=0; y<10; y++){
  		a[y][x] = 1;
  	}
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드:  0.200927734375 ms

console.time("최적화 코드");
function op (){
  let a = Array.from({length:10},()=>Array(10))
  for(let y=0; y<10; y++){
  	for(let x=0; x<10; x++){
  		a[y][x] = 1;
  	}
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.169921875 ms

위의 최적화가 안된 예시에서의 흐름을 보면 아래와 같다.
1. x=0
2. y=0
3. a[0][0] = 1;
4. a[1][0] = 1;
5. a[2][0] = 1;

중첩 배열에서 내부 반복문을 실행할 때, 외부 배열의 y인덱스로 접근하여 내부 배열의 x인덱스를 참조해야한다. 반면 최적화 예시에서 실행을 보면 아래와 같다.
1. y=0
2. x=0
3. a[0][0] = 1;
4. a[0][1] = 1;
5. a[0][2] = 1;

내부배열에서 순차적으로 다음 인덱스에 접근하고 내부배열에서의 접근이 끝나면 외부배열의 첫 인덱스부터 반복한다. 여기서 x 변수가 인덱스로 참조되는 지역성이 개선되었다는 걸 알 수 있다.

9. Loop Reversal

Loop Reversal은 인덱스 변수로 할당된 값들의 순서를 역순으로 하여 의존성을 줄이는데 도움이 된다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let a = Array(10);
  for(let x=0; x<10; x++){
     a[9-x] = 1;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드:  0.090087890625 ms

console.time("최적화 코드");
function op (){
  let a = Array(10);
  for(let x=9; x>=0; x--){
     a[x] = 1;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.070068359375 ms

10. Loop Splitting

다른 인덱스 범위를 순회하는 반복문으로 하나의 반복문을 나누는 방법이다. 이는 의존성을 줄이는데 도움이 되어 코드를 최적화한다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let a = Array(10);
  let b = Array(10);
  for(let x=0; x<10; x++){
     if(x<5) a[x] = 1;
     else  b[x] = 2;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드:   0.073974609375 ms

console.time("최적화 코드");
function op (){
  let a = Array(10);
  let b = Array(10);
  for(let x=0; x<5; x++){
     a[x] = 1;
  }
  for(let x=5; x<10; x++){
     b[x] = 2;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.0478515625 ms

11. Loop Peeling

Loop Peeling은 Loop Splitting의 특수한 케이스로, 반복문을 시작하기 전 예외적인 조건을 따로 먼저 처리하는 방식이다.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let a = Array(10);
  let b = Array(10);
  for(let x=0; x<10; x++){
     if(x===0) a[x] = 1;
     else  b[x] = 2;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드:   0.0361328125 ms

console.time("최적화 코드");
function op (){
  let a = Array(10);
  let b = Array(10);
  a[0] = 1;
  for(let x=1; x<10; x++){
     b[x] = 2;
  }
}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.029052734375 ms

12. Unswitching

반복문에서 조건문을 밖으로 빼내는 것. 반복문을 복자하고 각각의 버전을 조건문 안으로 넣는 방식.

console.time("최적화 되지 않은 코드");
function nonOp (){
  let a = Array(10);
  let b = Array(10);
  let s = 3;
  let t = 9;
  for(let x=0; x<10; x++){
     if(s>t) a[x] = 1;
     else  b[x] = 2;
  }
}
nonOp();
console.timeEnd("최적화 되지 않은 코드"); // 최적화 되지 않은 코드:   0.051025390625 ms

console.time("최적화 코드");
function op (){
  let a = Array(10);
  let b = Array(10);
  let s = 3;
  let t = 9;
  if(s>t){
    for(let x=0; x<10; x++){
      a[x] = 1;
    }  
  }else {
    for(let x=0; x<10; x++){
      b[x] = 2;
  	}
  }

}
op();
console.timeEnd("최적화 코드"); // 최적화 코드: 0.046142578125 ms

성능 최적화 팁

  • v8엔진이 만들어뒀던 히든 클래스들의 재사용성을 높이기 위해 동적 속성 추가를 지양하자.
  • 이전에 생성했던 히든 클래스를 재사용할 수 있도록 객체 속성은 항상 같은 순서로 초기화하자.

References

profile
https://pyotato-dev.tistory.com/ 로 이사중 🚚💨🚛💨🚚💨

0개의 댓글