I'm confused by how V8's ignition is executing my code. Based on everything I've read online, ignition creates bytecode, and then the individual bytecode instructions are handled by assembly stubs created by Turbofan. So I was expecting to see some loop where Ignition is calling these stub functions. But it appears that everything is computed in a single block of assembly, which the code enters here:
src/execution/execution.cc in Invoke
value = Tagged<Object>(
stub_entry.Call(isolate->isolate_data()->isolate_root(), orig_func,
func, recv, JSParameterCount(params.argc), argv));
this calls a function pointer which I assume is pointed to some pre-compiled binary because I can't step into it. And this seems to handle the entire execution of even the more involved script below.
I've also tried tweaking the bytecode array after Ignition generates it. This throws an error somewhere in assembly black box. So it kind of seems like the processing of bytecode instructions is all being handled in a builtin?
Would greatly appreciate it if anyone can help clarify the workings of Ignition!
I'm running V8 with the D8 shell. I'm using V8 version 12.1 and compiling to out/x64.debug. The behavior I describe above is the same, whether it's a simple test script:
const x = 2;
console.log(x);
Or a slightly more involved script:
const x = [1,2,3];
const y = [4,5,6];
const z = [];
for (let i=0; i<x.length; i++) {
z.push(x[i] + y[i]);
}
console.log(z[0], z[1], z[2]);
(V8 developer here.)
ignition creates bytecode, and then the individual bytecode instructions are handled by assembly stubs created by Turbofan.
Yes. Notably, these "assembly stubs" (we call them "bytecode handlers") are generated at V8 build time (by the mksnapshot
binary), and compiled into the final V8 binary. You can break inside them, but figuring out where to put the breakpoint is a bit involved, and all you'll get is an assembly view. If you're willing to recompile V8, an easier approach is to add a DebugBreak();
to the handler you're interested in, which emits an int3
instruction into it -- of course then you can only run that binary in a debugger, because non-debugged execution will terminate with a trap at that point; and you'll still only get an assembly view.
I was expecting to see some loop where Ignition is calling these stub functions
Ignition is an "indirect threading" interpreter: each bytecode handler ends with the instruction sequence that loads the next bytecode and jumps to the corresponding handler.
The idea behind this design is that the instruction sequence probably isn't random: for example, bytecode A might have a great chance of being followed by bytecode B. The indirect-threading design makes it easy for the CPU's branch predictor to learn about such cases, because (in this example) the indirect jump at the end of A will (almost) always jump to B, whereas the indirect jumps at the end of other handlers can learn their own most-probably jump targets.
In theory, modern CPUs can deal with the more intuitive loop-over-a-switch interpreter design reasonably well as well (thanks to taking massive amounts of history into account in their branch predictors), but that definitely makes the CPU's life harder, i.e. will likely have more dispatch overhead at least on weaker CPU designs, and maybe even everywhere.
Is there a single builtin that processes the fulls sequence of V8 Ignition's bytecode instructions?
No (see above), but there isn't a big C++ loop either: the bytecode handlers are tail-calling each other.
For a bunch more background, see v8.dev.