🤔 최근 성능 최적화에 대해 관심이 생기고 나서 lighthouse 등을 통해 인라인 css를 제거하거나, 이미지 preload 방식을 도입해서 LCP 줄이기, 혹은 리액트 useMemo 등을 통해 리랜더링을 최소화하는 방안 등, 자바스크립트 언어 본질적인 측면 외의 접근을 많이 했던 거 같았습니다.
✨ 기본이 제일 중요한 만큼, 코드를 이루고 있는 자바스크립트 자체의 최적화 방안들을 공부하며 정리해봤습니다.
🗣️ 내용 전달의 간결함을 위해 이후 내용은 비격식체로 작성했습니다.
이 V8엔진이 다른 엔진(mozilla Firefox의 spiderMonkey, apple Safari의 JavaScriptCore, 마이크로소프트의 Chakra)들과 다른 점이 JIT컴파일러가 있다는 점이다. JIT 컴파일러는 런타임에 자바스크립트를 기계어로 컴파일하고 중간코드(intermediate code)를 생성하지 않는다.
위의 그림에서 v8엔진은 핵심 부분 2가지로 나눌 수 있다.
🧐 V8이 자바스크립트 최적화에 쓰이지만 C++로 만들어졌고, 멀티쓰레드를 사용해 모든 작업을 동시에 다룬다.
// 함수 선언
function addition(x, y){
var answer = x + y;
console.log(answer);
}
// 함수 호출
addition(10, 20);
esprima를 통해 해당 코드를 파싱하면 아래와 같은 결과가 나온다.
{
"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 내에 코드의 각 행들이 정의된다.
함수 선언, 변수 선언, 이름, 타입 등이 줄마다 관리되며, 주석은 무시된다.
const userObject = {};
C01
을 생성한다.userObject.name = “Chameera”
C02
를 생성한다.C02
는 이전의 히든 클래스인 C01
의 모든 속성을 상속받고, offset 0
에 name 속성을 배정받는다.이를 통해 컴파일러가 객체 name을 찾을 때, v8은 바로 C01을 가르킬 것이다.
만약 이 객체에 또 다른 속성을 추가한다면 동일하게 동작한다.
즉, 새로운 히든 클래스가 생성되고, 이 히든 클래스는 전의 속성과 현재의 속성을 모두 지니게 된다.
userObject.blog = “blogURL”
히든 클래스의 개념은 검색을 용이하게 할 뿐만 아니라, 비슷한 객체가 생성되거나 변경되었을 경우 재사용 가능하도록 한다.
C01
클래스를 가리킨다.const articleObject = {};
하지만 articleName
라는 속성을 추가해 articleObject
을 변경한다면, C02은 name
이라는 속성만 지녔기 때문에 v8은 더 이상이전에 생성했던 클래스인 C02를 사용하지 못한다.
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 최적화를 통해서도 컴파일러 성능을 향상시킬 수 있습니다.
빈도 감소(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
반 이하로 줄어들었다.루프를 돌 때마다 특정 변수의 값이 변한다면 그 변수는 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은 고비용의 연산을 저비용의 연산으로 대신하는 것이다.
곱셈이 덧셈보다 비용이 더 드는 연산이다. 따라서, 루프 내에서 곱셈을 덧셈으로 변경해줄 수 있다.
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
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
반복문을 제거하거나 줄이는 방식이다. 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
두 개 이상의 루프를 하나의 루프로 합치는 것. 여러 루프를 컴파일하는데 시간을 줄일 수 있다.
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
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
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 변수가 인덱스로 참조되는 지역성이 개선되었다는 걸 알 수 있다.
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
다른 인덱스 범위를 순회하는 반복문으로 하나의 반복문을 나누는 방법이다. 이는 의존성을 줄이는데 도움이 되어 코드를 최적화한다.
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
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
반복문에서 조건문을 밖으로 빼내는 것. 반복문을 복자하고 각각의 버전을 조건문 안으로 넣는 방식.
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