Node.js Under the Hood #8 - Understanding Bytecodes

Lucas Santos - Feb 4 '20 - - Dev Community

We've been talking a lot about bytecodes lately. But what are bytecodes?

Bytecodes are abstractions of machine codes. Think of it as something in between the code we can read and the code machines execute. However, bytecodes are machine agnostic, which means that bytecodes can be compiled into whatever machine architecture you're running on – however, compiling bytecode to machine code is way easier if you generate bytecode which was designed with the same computational model as the underlying CPU.

CPUs are Turing machines that are either based on stacks, registers or states. V8's Ignition interpreter is a register-based interpreter with an accumulator as well as most of the CPUs.

In the end, bytecode is translated into assembly/machine code which can be sent to the processor and executed.

You can think of JavaScript as a series of small building blocks. Each operator (or sets of operators) have a bytecode notation in V8. So we have bytecodes for operators like typeof, add, sub and we also have operators for property loads like LdaSmi for small integers or LdaNamedProperty. The complete list can be found at the header file

Registers

Ignition uses registers like r0, r1, r2 ... to store bytecode inputs or outputs and specifies which ones to use. Along with input registers, Ignition also has an accumulator register, which stores the results of operations, we'll call it acc. It's pretty much the same as common registers but operands do not specify it at all, for instance, sub r0 is subtracting the value in r0 from the value in the accumulator, leaving the result value in the acc itself.

You'll see that many bytecodes start with Lda or Sta, the a stands for "accumulator", while Ld is "load" and St is "store". So, by intuition, LdaSmi [99] loads the small integer 99 into the accumulator, while Star r0 stores the value of the accumulator into the register r0.

This is because if we wrote: "LoadSmallIntToAccumulator" instead of "LdaSmi" we'd have to allocate more memory just to store the bytecode name. This is why bytecodes scare a lot of people.

Shorter bytecodes = less memory

Hands-on

Let's take a real bytecode from a real function in JavaScript. We're not using our readFile function since it'd be too complicated. Let's use this simple function:



function multiplyXByY (obj) {
  return obj.x * obj.y
}

multiplyXByY({ x: 1, y: 2 })


Enter fullscreen mode Exit fullscreen mode

A small note: V8 compiler is lazy, so if you don't run a function, it is not compiled, which means it does not generate any bytecode.

This will generate the following bytecode:



[generated bytecode for function: multiplyXByY]
Parameter count 2
Register count 1
Frame size 8
   22 E> 0x334a92de11fe @    0 : a5                StackCheck
   43 S> 0x334a92de11ff @    1 : 28 02 00 01       LdaNamedProperty a0, [0], [1]
         0x334a92de1203 @    5 : 26 fb             Star r0
   51 E> 0x334a92de1205 @    7 : 28 02 01 03       LdaNamedProperty a0, [1], [3]
   45 E> 0x334a92de1209 @   11 : 36 fb 00          Mul r0, [0]
   52 S> 0x334a92de120c @   14 : a9                Return
Constant pool (size = 2)
Handler Table (size = 0)


Enter fullscreen mode Exit fullscreen mode

You can generate and print your bytecode using the command node --print-bytecode --print-bytecode-filter=functionName <file>

Let's ignore the header and the footer since it's just metadata for the bytecodes.

LdaNamedProperty a0, [0], [1]

This bytecode loads a named property of a0 – Ignition identifies parameters as a0, a1, a2..., the number is the index of the argument, so a0 is the first argument of the function (obj) – into the accumulator.

In this particular bytecode, we're looking up the named property on a0, so we're loading the first argument of the function, which is obj. The name we're looking at is determined by the first parameter: [0]. This constant is used to look up the name in a separate table – which can be accessed in the Constant Pool part of the print, but only in Node.js debug mode:



0x263ab302cf21: [FixedArray] in OldSpace
 - map = 0x2ddf8367abce <Map(HOLEY_ELEMENTS)>
 - length: 2
           0: 0x2ddf8db91611 <String[1]: x>
           1: 0x2ddf8db67544 <String[1]: y>


Enter fullscreen mode Exit fullscreen mode

So we see that position 0 is x. The [1] is the index of what is called "feedback vector", which contains runtime information that is used for optimizations.

Star r0

Star r0 stores the value that is currently in the accumulator, which is the value of the x index we just loaded, in the register r0.

LdaNamedProperty a0, [1], [3]

This is the same thing, but we're now loading the index 1, which is y.

Mul r0, [0]

This operation multiplies the value that is currently in the accumulator (y) by r0 (x) and stores the result into the accumulator.

Return

The return statement returns the value that is currently in the accumulator. It's also the end of the function. So the function caller will start with the result of our last bytecode operation – which is 2 – already in the accumulator.

Takeaways

Most bytecodes may seem gibberish at first glance. But keep in mind that Ignition is a register machine with an accumulator, that is basically how we can simply understand how it works.

This would be the bytecode for our readFile function:



[generated bytecode for function: readFileAsync]
Parameter count 2
Register count 3
Frame size 24
         0x23e95d8a1ef6 @    0 : 84 00 01          CreateFunctionContext [0], [1]
         0x23e95d8a1ef9 @    3 : 16 fb             PushContext r0
         0x23e95d8a1efb @    5 : 25 02             Ldar a0
         0x23e95d8a1efd @    7 : 1d 04             StaCurrentContextSlot [4]
  261 E> 0x23e95d8a1eff @    9 : a5                StackCheck
  279 S> 0x23e95d8a1f00 @   10 : 13 01 00          LdaGlobal [1], [0]
         0x23e95d8a1f03 @   13 : 26 fa             Star r1
         0x23e95d8a1f05 @   15 : 81 02 00 02       CreateClosure [2], [0], #2
         0x23e95d8a1f09 @   19 : 26 f9             Star r2
         0x23e95d8a1f0b @   21 : 25 fa             Ldar r1
  286 E> 0x23e95d8a1f0d @   23 : 65 fa f9 01 02    Construct r1, r2-r2, [2]
  446 S> 0x23e95d8a1f12 @   28 : a9                Return
Constant pool (size = 3)
Handler Table (size = 0)


Enter fullscreen mode Exit fullscreen mode

We can see it has a series of bytecodes specifically designed to several aspects of the language, such as closures, globals and so on... Can you read that bytecode? Leave it here in the comments :)

Thanks

A big thanks to Franziska Hinkelmann, her articles and talks about V8 bytecodes are simply awesome and helped me a lot when I started studying this topic. Especially this article!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .