왜 자바스크립트는 c++보다 느릴까?
자바스크립트와 c++을 비교했을 때, 일단 제 눈에 제일 띄는 부분은 다음과 같습니다.
타입 명시를 안해도 되는 것.
int myInteger = 3;
var myVariable = 3
타입 명시가 필요없는 이유는 자바스크립트 런타임 시, 알아서 타입을 추론하기 때문입니다. 이걸 동적 타이핑(Dynamic typing)이라 합니다.
이 피쳐는 개발자 입장에선 편할지 몰라도 컴파일러 입장에선 굉장히 부담스럽습니다. 다음 예시를 한번 볼까요?
const call = (obj) => obj.x;
잉...? 이게 뭐가 문제지? 싶은데, 컴파일러 입장에서 봅시다.
사용자가 'call'을 호출 시, 컴파일러 입장에선 꽤나 난감합니다.
왜냐면 컴파일러 입장에서 'x'에 대한 정보가 아무것도 없거든요.
obj가 x를 프로퍼티로 가지고 있는지 , 혹시 obj 프로토타입 체인 아래에 존재하는지, obj의 프로퍼티들이 어떤 식으로 저장되어있는지, x의 값에 대한 메모리 위치는 어딧는지, 애당초 obj의 위치는 어딧는지 etc..
꽤나 부담스럽겠죠?
그리고 이런 요소가 c++보다 JS가 느리게 되는 하나의 요소입니다.
허나, JS는 동적 타이핑인 것을 감안하더라도 생각보다 빠릅니다.
How does JS Engine works?
그 이유는 자바스크립트 컴파일 방식에 있습니다.
먼저 c++ 은 다음과 같은 과정을 거쳐 실행됩니다.
먼저 컴파일러, 어셈블러 , 링커 ... 에 의해 실행 파일이 만들어지고, 해당 실행 파일을 실행합니다.
반면 JS는, 코드를 컴파일 함과 동시에, 실행 합니다.
이를 JIT (Just In Time) Compilation 라고 합니다.
머신 코드를 런타임에 생성하는 거죠, 이후가 아니라.
소스를 컴파일하면서 컴파일링이 된 부분을 이용하여 추후 컴파일 태스크에 대해 활용합니다.
그리하여 전후로 지속적으로 피드백을 보내, 실행 속도를 부스팅하는 겁니다.
그리고 이러한 작업을 할 수 있도록 2개의 컴파일러가 사용됩니다. 참고로 최신 브라우저는 전부 두 개이상의 컴파일러를 지원합니다. (구식 브라우저는 1개만 씁니다.)
크롬 V8엔진의 경우,
baseline compiler : Ignition
optimising compiler : Turbo Fan
파이어폭스 Spider Monkey엔진의 경우,
baseline compiler : spider Monkey
optimising compiler : Iron Monkey
이렇게 부릅니다. 혹시 더 알고 싶으면 해당 키워드로 찾아보세요.
더 깊이 들어가기 전에 하나 알아둬야 할 것이 있는데, 객체가 내부적으로 표현되는 과정에 대해 알 필요가 있습니다.
자바스크립트에서 객체 타입의 경우 모든 속성을 새로운 타입으로 전환(transition)하면서 점진적(incremental)으로 나타냅니다.
이게 무슨 뜻이냐면, 아래의 변수 q의 경우,
var q = {
x: 1,
y: 2
}
내부적으로 다음과 같이 표현됩니다.
//1
q = {} -> {x : number} -> {x : number , y : number}
순차적으로 타입에 대한 정보가 전환 됩니다. 그리고 실제로 이러한 정보들을 트래킹하고 있기 때문에, 두 객체가 완전히 동일한 속성을 갖고 있다 하더라도 내부적으로는 전혀 다른 객체로 인식됩니다.
ex)
//q
{} -> {x: number} -> {y: number, x: number}
//p
{} -> {y: number} -> {x: number, y: number}
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
Optimising Compiler(최적화 컴파일러)의 역할은 간단합니다.
Re-compiling hot functions with type information from previous execution , de-optimize if the type has changed.
여기서 hot function은 많이 호출되는 함수입니다. 캐시 전략처럼 당연하지만 많이 쓰는 함수(포괄적인 의미)의 경우 당연히 최적화하면 퍼포먼스 향상에 도움이 되겠죠? 그리고 캐시에 대해 대응해보자면, cache miss 시, 즉, 타입이 바뀔 시, 최적화 해제를 합니다.
전체적으로 동작하는 방식은 아래와 같습니다.
먼저 소스파일이 parser에 의해 AST 스트림으로 변환 됩니다.
//AST ex
{
"type": "Program",
"start": 0,
"end": 6,
"range": [
0,
6
],
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 6,
"range": [
0,
6
],
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 5,
"range": [
4,
5
],
"id": {
"type": "Identifier",
"start": 4,
"end": 5,
"range": [
4,
5
],
"name": "a"
},
"init": null
}
],
"kind": "var"
}
],
"sourceType": "module"
}
그리고 baseline 컴파일러 + optimising 컴파일러에 의해 컴파일 되는데, intel 프로세서 기준 아래 와 같습니다.
//실행코드
function load(obj) {
return obj.x;
}
load({ x: 1, a: 3 });
load({ x: 1, a: 3 });
load({ x: 1, a: 3 });
load({ x: 1, a: 3 });
;; 원래 더 많습니다 , 현재 같은 함수를 여러번 호출하여 타입을 기억시켜놓은 상태입니다
0x1185c5082 c2 488945d0 REX.W movq [rbp-0x30],rax ;; 함수 처리를 위해 스택 셋팅
0x1185c5086 c6 4c8b45f8 REX.W movq r8,[rbp-0x8]
0x1185c508a ca 498b809f000000 REX.W movq rax,0x10eb9ff40 ;; 현재 추론이 필요한 객체
0x1185c5091 d1 493b4518 REX.W cmpq rax,[r13+0x18] ;; 비교하고자하는 패러미터 타입과 기존에 저장된 타입과 비교
0x1185c5095 d5 0f8527000000 jnz 0x1185c50c2 <+0x102> ;; 만약 위에서 비교 결과가 true가 아닐시, opcode : 102로 이동
0x1185c509b db 488b75f8 REX.W movq rsi,[rbp-0x8]
0x1185c509f df 49ba49da0224d70b0000 REX.W movq r10,0xbd72402da49 ;; 0x0bd72402da49에 타입 정보가 들어있음, 해당 정보 로딩
.....
;; 아래가 opcode 102!
0x1185c50c2 102 488b55d0 REX.W movq rdx,[rbp-0x30] ;; false, 여기로 넘어왔단건 input param타입이 새로운 타입이란 뜻
0x1185c50c6 106 bb04000000 movl rbx,0x4
0x1185c50cb 10b 41ff95c0430000 call [r13+0x43c0] ;; de-optimise, bailout!!!
주요 과정을 설명하자면 다음과 같습니다.
(hot function 실행 시,)
1. 현재 추론하고자 하는 데이터를 불러옵니다
2. 기존에 저장되어 있던 타입과 비교합니다
3-1. 만약 일치한다면, 데이터 값을 불러오고 반환합니다. (끝)
3-2. 만약 일치하지 않다면, 현재 데이터 타입이 바뀌었단 뜻입니다. optimising 컴파일러에 de-optimise를 진행합니다.
4-2. 새로운 타입이므로 baseline 컴파일러에 의해 일반적인 머신 코드에 의해 이어서 연산합니다.(끝)
근데 만약 다음과 같은 경우는 어떨까요?
//실행코드
function load(obj) {
return obj.x;
}
load({ x: 1, a: 3 });
load({ x: 1, b: 4 });
load({ x: 1, c: 5 });
load({ x: 1, d: 6 });
프로퍼티 x까지는 이전에 기록한 모든 입력에 해당하므로, 두번째 프로퍼티 연산 시(d), 3번의 비교를 하게 됩니다.
그렇게 하여, 만약 타입이 일치하면 즉시 값을 로드, 불일치 시, baseline 컴파일러에 의해 이어서 연산을 수행합니다.
해당 방식에 대해 혹시 문제점을 느끼셨나요?
서로 다른 두번째 프로퍼티를 갖는 객체를 인자로 load 를 매우 많이 호출한다면, 그만큼 비교 구문이 많아진다고 생각할 수 있습니다만,
실제로는 4개 이상의 유형이 있을 경우, 더 이상 비교를 하지 않고,
0x11140599c 9c 48b8d92138688e040000 REX.W movq rax,0x48e683821d9 ;; object: 0x048e683821d9 <String[1
...
0x111405e24 524 41ffd2 call r10 ;; LoadICTrampoline 그리고 최종적으로 3000개의 엔트리까지 가용 가능한 풀에서 타입을 찾는 함수 호출
다음과 같은 과정을 수행합니다.
1. 프로퍼티 x를 문자열로 인식합니다.
2. 로딩에 관한 트램펄린 함수 호출을 통해 최대 3000개의 엔트리가 존재하는 메모리 공간으로 이동합니다.
3. 해당 공간에서 x의 타입을 찾습니다.
별거 아닌거 같아 보이지만, 해당 작업은 정말 고비용입니다.
여기서 한 가지 최적화 가능한 방법을 도출해낼 수 있는데요, "항상 동일한 유형의 객체를 사용" 하는 것입니다.
즉 다음과 같이 말이죠.
load({ x: 1, a: 3, b: undefined, c: undefined, d: undefined });
load({ x: 2, a: undefined, b: 4, c: undefined, d: undefined });
load({ x: 3, a: undefined, b: undefined, c: 5, d: undefined });
load({ x: 4, a: undefined, b: undefined, c: undefined, d: 6 });
번외로, 동적 key를 사용하는 경우, 내부적으로 해당 키를 입력으로 삼는 Symbol을 사실상 key로 삼기 때문에 퍼포먼스 저하는 없습니다.
참고 자료)
https://blog.bitsrc.io/how-does-javascript-work-part-2-40cc15360bc
How Does JavaScript Really Work? (Part 2)
How memory management, call stack, threading, and the event loop works with JavaScript’s V8 engine.
blog.bitsrc.io
JSconfig 영상인데 제목이 기억이 안나네요..
'Javascript' 카테고리의 다른 글
[Wasm] 웹어셈블리(c++) 오버헤드 테스트 (1) | 2024.07.04 |
---|---|
[javascript] 불변성(Immutability)을 위한 자료구조적 최적화 (1) | 2023.09.03 |
[Javascript + Web] Event loop & Async (이벤트 루프 & 비동기) (0) | 2023.03.29 |
[Javascript] Generator (제너레이터) (0) | 2023.03.12 |
[Javascript] Symbol (0) | 2023.03.09 |