Link Search Menu Expand Document

2022-05-10

[javascript] 컴파일된 js파일의 바이트 코드, 기계어 분석

node에는 여러 실행 옵션들이 있는데 js 파일을 바이트 코드, 혹은 기계어(어셈)으로 컴파일된 내용을 출력하는 옵션이 있어. 그래서 오늘은 이걸 실행한 내용을 슬쩍 살펴볼거야.

일단 아래 명령을 실행하면 엄청나게 많은 v8 옵션들을 볼 수 있어.

> node --v8_options

Options:
 --abort-on-contradictory-flags (Disallow flags or implications overriding each other.)
    type: bool default: --noabort-on-contradictory-flags
 --allow-overwriting-for-next-flag (temporary disable flag contradiction to allow overwriting just the next flag)
    type: bool default: --noallow-overwriting-for-next-flag
 --use-strict (enforce strict mode)
    type: bool default: --nouse-strict
....

이 옵션에는 로그를 추가하는 것도 있고 최적화 기능 끄기, 일부만 켜기, GC 옵션 등등.. 디버깅에 필요한 기능이 대부분 존재해. 이 중에서 바이트 코드를 출력하는 옵션은 --print_bytecode, 기계어 출력 옵션은 --print-builtin-code 로 지정되어 있어.

일단 node로 실행할 예제인 plus.js 파일을 아래처럼 만들었어.

function plus_one(obj) {
 return obj.xxx + 1;
}
console.log('result:', plus_one({xxx:23}));

object를 받아서 멤버 xxx값에 1을 더해서 반환하게 되는 4줄짜리 간단한 실행 파일이야. 
이걸 이제 bytecode랑 builtin-code로 파일에 출력하면,

node --print_bytecode plus.js > bytecode.log
node --print-builtin-code plus.js > builtin_code.log

이렇게 큰 용량의 파일이 만들어지게 돼.

-rw-r--r-- 1 visco staff  2098830 May 9 21:54 bytecode.log
-rw-r--r-- 1 visco staff 18304671 May 9 21:55 builtin_code.log


이제 준비는 되었으니 bytecode.log 파일 먼저 볼게. 일단 열어보면 이런 식의 함수별 단락이 수없이 많아.

[generated bytecode for function: (0x26ea2b185911 <SharedFunctionInfo>)]
Bytecode length: 5
Parameter count 1
Register count 0
Frame size 0
OSR nesting level: 0
Bytecode Age: 0
  0 E> 0x26ea2b185a56 @  0 : 7f 00 00 00    CreateClosure [0], [0], #0
 632 S> 0x26ea2b185a5a @  4 : a8        Return
Constant pool (size = 1)
0x26ea2b185a09: [FixedArray] in OldSpace
 - map: 0x261e256c12c1 <Map>
 - length: 1
      0: 0x26ea2b1859d1 <SharedFunctionInfo>
Handler Table (size = 0)
Source Position Table (size = 7)
0x26ea2b185a61 <ByteArray[7]>

함수 단위로 바이트 코드 크기와 파라미터, 그리고 실제 생성되는 bytecode hex값이 있는걸 볼 수 있어. 예를 들어 이 함수의 bytecode는 7f 00 00 00 a8 으로 5byte 크기인거고. 대충 알았으니 이제 예제에서 내가 만든 함수인 plus_one을 찾아봐야지? 

[generated bytecode for function: plus_one (0x04296caea289 <SharedFunctionInfo plus_one>)]
Bytecode length: 8
Parameter count 2
Register count 0
Frame size 0
OSR nesting level: 0
Bytecode Age: 0
  38 S> 0x4296caeab6e @  0 : 2d 03 00 01    LdaNamedProperty a0, [0], [1]
  42 E> 0x4296caeab72 @  4 : 44 01 00     AddSmi [1], [0]
  46 S> 0x4296caeab75 @  7 : a8        Return
Constant pool (size = 1)
0x4296caeab21: [FixedArray] in OldSpace
 - map: 0x261e256c12c1 <Map>
 - length: 1
      0: 0x04296caea0d1 <String[3]: #xxx>
Handler Table (size = 0)
Source Position Table (size = 8)
0x04296caeab79 <ByteArray[8]>

plus_one이라는 함수로 생성되었고, 8byte 크기에 파라미터는 2개를 가지고 있어. 근데 내가 지정한 함수는 파라미터가 하나였잖아? 나머지 하나는 this를 암묵적으로 가져간다고 보면 돼. bytecode가 세줄밖에 안되니까 한줄씩 잠깐 보면,

1) LdaNamedProperty a0, [0], [1]
a0 레지스터에 있는 object에서 map에 등록된 named property 첫번째 항목인 xxx를 가져오라는 뜻이야. xxx 인덱스가 0인 이유는 위에 FixedArray 항목의 ' 0: 0x04296caea0d1 <String[3]: #xxx>' 로 xxx 값이 0번 인덱스와 연결되어 있어서 그래. 그 뒤에 [1]은 피드백 벡터인데 신경쓰지 않아도 되는 값이야.

2) AddSmi [1], [0]
레지스터에 1을 더하래. 제일 뒤에 [0]은 피드백 벡터. 근데 AddSmi를 실제 어셈으로 하면 'add eax, 1' 이런 식으로 나올텐데 저기서 타겟 레지스터가 없는 이유는 어차피 더하는 곳을 알고 있으니 이걸 임의로 삭제한 거야. 이러면 bytecode 용량도 줄이고 컴파일도 그만큼 빨라지겠지?

3) Return
레지스터에 저장된 값을 반환.

그래서 plus_one 함수의 바이트 코드는 '2d 03 00 01 44 01 00 a8' 인 8byte로 구성이 돼. 


이렇게 나온 바이트 코드를 실행하기 위해서는 기계어로 변경해야겠지? 여기서부터는 플랫폼에 따라 다르게 생성되어야 하는데 ia32를 보면 v8 코드 내에서 src/builtins/ia32/builtin-ia32.cc 파일에 있는 함수를 사용하여 실제 기계어(어셈)로 변경하게 돼. ia32에서 바이트코드가 기계어로 변경되는 부분을 보면

static void Generate_InterpreterEnterBytecode(MacroAssembler* masm) {
 Label builtin_trampoline, trampoline_loaded;
 Smi interpreter_entry_return_pc_offset(
   masm->isolate()->heap()->interpreter_entry_return_pc_offset());

 static constexpr Register scratch = ecx;
 __ mov(scratch, Operand(ebp, StandardFrameConstants::kFunctionOffset));
 __ mov(scratch, FieldOperand(scratch, JSFunction::kSharedFunctionInfoOffset));
 __ mov(scratch,
     FieldOperand(scratch, SharedFunctionInfo::kFunctionDataOffset));
 __ Push(eax);
 __ CmpObjectType(scratch, INTERPRETER_DATA_TYPE, eax);
 __ j(not_equal, &builtin_trampoline, Label::kNear);
....

대충 이렇게 생겼어. 여기서 mov 함수를 호출하게 되는데 저 함수는 src/codegen/ia32/assembler-ia32.cc 에 아래처럼 정의되어 있어.

void Assembler::mov(Register dst, Operand src) {
 EnsureSpace ensure_space(this);
 EMIT(0x8B);
 emit_operand(dst, src);
}

void Assembler::emit_operand(int code, Operand adr) {
 const unsigned length = adr.encoded_bytes().length();
 EMIT((adr.encoded_bytes()[0] & ~0x38) | (code << 3));
 for (unsigned i = 1; i < length; i++) EMIT(adr.encoded_bytes()[i]);
}

기계어 0xB8(mov 동작을 뜻하는 opcode)을 emit하고, 그 다음에 operand 값을 계산해서 emit하게 돼. emit 구현부는 이전에 쓴 글에 자세히 설명했으니 참고해.
https://kr.teamblind.com/s/fsLNT1CO

이렇게 emit이 되면 드디어 바이트 코드가 기계어로 번역되었다고 할 수 있어. 


이젠 기계어로 번역된 파일인 builtin_code.log를 볼 차례야. 이 파일은 18MB로 빌트인 함수나 최초 실행에 필요한 기반 코드가 많이 필요해서 그래. 바이트 코드도 동일하지만 그건 아직 해석이 안되어서 조금 더 작은듯. 일단 함수 하나만 보면,

kind = BUILTIN
name = CloneObjectICBaseline
compiler = turbofan
address = 0x1ef17e5505d1

Trampoline (size = 4)
0x1ef17e550630   0 d4200000    brk #0x0

Instructions (size = 28)
0x1005054e0   0 aa1d03e4    mov x4, fp
0x1005054e4   4 f85d8085    ldur x5, [x4, #-40]
0x1005054e8   8 f85f8084    ldur x4, [x4, #-8]
0x1005054ec   c aa0403fb    mov cp, x4
0x1005054f0  10 aa0503e3    mov x3, x5
0x1005054f4  14 17fffe0b    b #-0x7d4 (addr 0x100504d20)
0x1005054f8  18 d503201f    nop

Safepoints (size = 8)
RelocInfo (size = 0)

이게 제일 작은 편인 함수야. 보통 몇 페이지가 넘어가는 함수들이 워낙 많아서 고르고 골랐어. 근데 사실 기계어로 출력된 파일은 봐도 건질게 없더라. 내가 넣은 plus_one 함수는 이미 최적화되어서 안에 묻힌건지 사방을 뒤져봐도 찾을 수가 없는데 18MB를 다 뒤져볼 수도 없고 그러네. 따로 옵션을 넣어야 하는건지..

어쨌든 대충 저렇게 생긴 기계어로 출력되는데 더 깊이 들어가도 큰 실익이 없을 것 같으니 여기서 마무리할게.

결론)
1) node 옵션 중에는 js파일이 컴파일된 바이트 코드(--print_bytecode), 기계어(--print-builtin-code) 출력이 가능하다
2) 아키텍쳐가 달라도 바이트 코드는 동일하게 나오고, 이를 기계어로 번역할 때에 분기하여 아키텍쳐마다 다르게 생성한다.
3) 기계어 출력 옵션은 구글 형들만 쓰는걸로..


이틀 연속 달렸더니 버겁네. 조금 쉬다가 또 재미있어 보이는 주제 있으면 들고 올게.

#javascript #v8