Node has a variety of runtime options, and one of them prints the contents of a JS file compiled into bytecode, or into machine code (assembly). So today, I'm going to take a quick look at what you get when you run that.
First, if you run the command below, you can see an enormous number of V8 options.
> 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
....
These options include things like adding logs, turning optimization features off, enabling only certain parts, GC options, and so on. Most of the features needed for debugging are there. Among them, the option for printing bytecode is --print_bytecode, and the option for printing machine code is --print-builtin-code.
First, I created a plus.js file like this as an example to run with Node.
function plus_one(obj) {
return obj.xxx + 1;
}
console.log('result:', plus_one({xxx:23}));
It's a simple four-line executable file that takes an object, adds 1 to the value of its xxx member, and returns it.
Now, if you print this to files as bytecode and builtin-code,
node --print_bytecode plus.js > bytecode.log
node --print-builtin-code plus.js > builtin_code.log
you get files this large.
-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
Now that everything is ready, I'll look at bytecode.log first. When you open it, there are countless function-by-function sections like this.
[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]>
You can see the bytecode size, parameters, and the actual generated bytecode hex values for each function. For example, this function's bytecode is 7f 00 00 00 a8, which is 5 bytes in size. Now that we have a rough idea, we need to find the plus_one function I wrote in the example, right?
[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]>
It was generated as a function named plus_one, has a size of 8 bytes, and has 2 parameters. But the function I defined had only one parameter, right? You can think of the other one as this being passed implicitly. Since the bytecode is only three lines, let's briefly look at each line.
1) LdaNamedProperty a0, [0], [1]
This means to get xxx, the first named property registered in the map, from the object in the a0 register. The reason the xxx index is 0 is that, in the FixedArray entry above, ' 0: 0x04296caea0d1 <String[3]: #xxx>' links the xxx value to index 0. The [1] after that is the feedback vector, and you don't need to worry about it.
2) AddSmi [1], [0]
It says to add 1 to the register. The [0] at the very end is the feedback vector. But if AddSmi were actual assembly, it would look something like 'add eax, 1', so the reason there is no target register there is that it already knows where the addition is happening, and that part has been omitted. This reduces the bytecode size and makes compilation that much faster, right?
3) Return
Return the value stored in the register.
So the plus_one function's bytecode is made up of the 8 bytes '2d 03 00 01 44 01 00 a8'.
To execute bytecode produced this way, it has to be converted into machine code, right? From here on, it has to be generated differently depending on the platform. Looking at ia32, V8 uses functions in the src/builtins/ia32/builtin-ia32.cc file to convert it into actual machine code (assembly). If you look at the part where bytecode is converted into machine code on 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);
....
it roughly looks like this. Here it calls the mov function, and that function is defined in src/codegen/ia32/assembler-ia32.cc as follows.
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]);
}
It emits the machine code 0xB8 (the opcode that means the mov operation), then calculates and emits the operand value. I explained the emit implementation in detail in a previous post, so please refer to that.
https://kr.teamblind.com/s/fsLNT1CO
Once it has been emitted like this, you can finally say that the bytecode has been translated into machine code.
Now it's time to look at builtin_code.log, the file translated into machine code. This file is 18MB because it needs a lot of built-in functions and foundational code required for initial execution. The same goes for bytecode, but since that hasn't been interpreted yet, it seems a little smaller. If we look at just one function first,
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)
This is one of the smallest functions. Most functions span several pages, so I had to pick carefully. But honestly, even after looking at the machine-code output file, there wasn't much to take away from it. I couldn't find the plus_one function I put in, no matter where I looked. Maybe it was already optimized and buried inside, but I can't exactly search through all 18MB. Maybe I need to pass another option separately..
Anyway, the machine code output roughly looks like that, and I don't think there is much practical benefit in going deeper, so I'll wrap it up here.
Conclusion)
1) Among Node's options, you can print a JS file's compiled bytecode (--print_bytecode) and machine code (--print-builtin-code).
2) Even when the architecture differs, the bytecode comes out the same, and when translating it into machine code, it branches and generates different code for each architecture.
3) Let's leave the machine-code output option to the Google folks..
Running hard for two days in a row has been rough. I'll rest a bit and bring another interesting-looking topic if I find one.
#javascript #v8