The last time I looked closely at how PHP executes bytecode, the engine had one handler per opcode and those handlers were written by hand. Specialised handlers arrived with PHP 5.1 in November 2005, and two of the five dispatch models in this article did not exist yet either. Curious how these dispatch models differ and what use cases they solve, I dug in. In this article I want to share with you what I learned along the way.
What follows is a tour of the part of the Zend Engine that actually runs your code. It touches C and the machine underneath, so I will explain the background as we go. You do not need to know either to follow along.
From PHP code to opcodes
PHP does not run your source code. It compiles it into an intermediate representation made of instructions that the engine calls opcodes. A compiled script is an array of such instructions, and a component called the executor walks that array and performs one instruction after the other.
We can look at that array. OPcache brings a debug facility for this, controlled by the opcache.opt_debug_level setting. Its value is a bit mask: 0x10000 prints the bytecode as the compiler produced it, 0x20000 prints it again after the optimiser has been over it. Take the smallest program I can think of:
<?php declare(strict_types=1); print 2 + 3;
OPcache has to be loaded and enabled for the command line, which makes for a wordy invocation:
$ php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 program.php
If you would rather not deal with OPcache at all, phpdbg -p -r program.php prints a very similar listing. Either way, this is what the compiler produced:
$_main:
; (lines=2, args=0, vars=0, tmps=0)
; (before optimizer)
0000 ECHO int(5)
0001 RETURN int(1)
There is no addition here. The compiler evaluated 2 + 3 while compiling and put the result into the instruction. That is a compiler concern and not our topic today, but it is a useful reminder that the executor only ever sees what survived compilation. To get an actual addition, we have to hide the operands from the compiler:
<?php declare(strict_types=1); $a = 2; $b = 3; print $a + $b;
$_main:
; (lines=5, args=0, vars=2, tmps=3)
; (before optimizer)
0000 ASSIGN CV0($a) int(2)
0001 ASSIGN CV1($b) int(3)
0002 T4 = ADD CV0($a) CV1($b)
0003 ECHO T4
0004 RETURN int(1)
Five instructions. Each has an address, an operation, up to two operands, and possibly a result. CV0 and CV1 are compiled variables: slots for $a and $b that the compiler reserved in the call frame, addressed by number rather than by name. T4 is a temporary that only exists between the ADD and the ECHO.
In C, one such instruction is a struct named _zend_op, declared in Zend/zend_compile.h. A struct in C is what a class without methods would be in PHP: a fixed set of named fields:
struct _zend_op { zend_vm_opcode_handler_t handler; znode_op op1; znode_op op2; znode_op result; uint32_t extended_value; uint32_t lineno; uint8_t opcode; /* Opcodes defined in Zend/zend_vm_opcodes.h */ uint8_t op1_type; /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */ uint8_t op2_type; /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */ uint8_t result_type; /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */ };
Two of these fields matter for what follows.
opcode is a single byte, so there can be at most 256 different operations, and PHP 8.6 uses 212 of them. It is the number behind the names you see in an opcode dump: ADD, ECHO, ASSIGN, RETURN.
handler is the field the executor actually uses. While a script is being compiled, every instruction gets a handler assigned that says how this instruction is to be performed. At runtime the executor goes straight to handler and, apart from a handful of special cases, never looks at opcode at all. That field is mostly there for the optimiser, for the JIT, and for tools that inspect the bytecode.
handler. The opcode field is read by the optimiser, by the JIT and by tools such as phpdbg.
What a handler actually is, whether a function to call, a number to switch on, or an address to jump to, is the question this whole article revolves around.
What an executor has to do
Stripped of everything else, an executor keeps track of two things:
-
oplinepoints to the instruction that is currently being performed. It is the equivalent of a bookmark, and it is what a CPU would call an instruction pointer. -
execute_datapoints to the current call frame: the block of memory that holds the arguments, the compiled variables and the temporaries of the function that is currently running.CV0in the dump above means "slot 0 inexecute_data".
With those two, the job is a loop: look at opline, do what it says, move opline forward, repeat. Moving forward is spelled out in a macro in Zend/zend_execute.c; a macro in C is a piece of text that the compiler pastes in wherever the macro is used:
#define ZEND_VM_NEXT_OPCODE_EX(check_exception, skip) \ CHECK_SYMBOL_TABLES() \ if (check_exception) { \ OPLINE = EX(opline) + (skip); \ } else { \ ZEND_ASSERT(!EG(exception)); \ OPLINE = opline + (skip); \ } \ ZEND_VM_CONTINUE()
The last line is the one that matters here. ZEND_VM_CONTINUE() means "and now go on to the next instruction", and the whole point of this article is that there are five different ways to spell that out. Handing control from one instruction to the next is called dispatch, and it is the hot spot of every interpreter.
The executor is reached through execute_ex(), which is not called directly. The engine calls through a function pointer named zend_execute_ex that points at execute_ex() by default. Extensions can replace it, and that is how Xdebug gets between you and your code. It is also one of the reasons why Xdebug slows PHP down so much: every user-land function call then goes through a different, much more expensive executor.
Calling a PHP function, on the other hand, does not as a rule mean calling execute_ex() again. When one PHP function calls another, the executor pushes a new call frame, points execute_data and opline at it, and carries on in the same loop. Your PHP call stack and the C call stack are two different things.
execute_ex() frame on the C stack, no matter how many PHP functions have called each other. Once Xdebug replaces the executor, the C stack grows with every call in userland.
A detour through the CPU
The five dispatch models differ in ways that only make sense if you know a little about what a processor does with the code a C compiler produces, so here is the minimum. I had to refresh most of this myself. The last time I felt properly at home with a processor, it was the Motorola 68000, where the manual listed a cycle count for every instruction and you could add them up to know how long a routine would take. Nearly everything in this section came after that.
A CPU executes machine instructions, and it keeps a handful of extremely fast storage slots called registers. A modern 64-bit x86 processor has sixteen general-purpose ones. Registers are the only place where the CPU can actually compute; everything else has to be loaded from memory into a register first. Deciding what lives in a register and what has to be re-loaded from memory is one of the main jobs of a C compiler.
When one C function calls another, there is a protocol both sides follow, called the calling convention. Arguments go into agreed registers and the return address goes on the stack. The expensive part is that some registers must survive the call. Those are the callee-saved registers: if the called function wants to use one, it has to save the old value first and restore it before returning. That bookkeeping is negligible for a single call and adds up over the billions of calls an interpreter makes.
The other thing to know is that a CPU does not perform one instruction at a time and then look at the next one. It has a pipeline that is dozens of stages deep and it works far ahead of what it has actually finished. That only works as long as it knows which instruction comes next, so whenever it reaches a jump whose target is not fixed, it predicts the target and speculatively runs ahead on that assumption. A correct prediction costs nothing, and a wrong one throws away all the speculative work at a cost in the order of fifteen to twenty cycles. This is where my cycle counting stopped working: what an instruction costs now depends on what the processor guessed a moment earlier.
Dispatching an opcode is such a jump. It is an indirect jump: the target is not written into the instruction but read from memory, from that handler field. The CPU's predictor keeps its history per jump site, keyed by the address of the jump instruction itself. That detail drives much of the design of PHP's executor.
If all opcodes are dispatched from one shared jump instruction, that one predictor entry has to guess, for every instruction of every script the process runs, which of a thousand handlers comes next. It will be wrong most of the time. If instead every handler ends with its own jump instruction, each of those jumps gets its own history, and the predictor can learn that a comparison is usually followed by a conditional jump, that a variable fetch is usually followed by an assignment, and so on. The work performed is identical, but the processor guesses right far more often. That difference is what the five models are about.
An executor that is generated
One more thing before the models themselves: nobody writes PHP's executor by hand. It is generated by a PHP script.
Two files in Zend/ are the input. zend_vm_def.h contains a template for each opcode. zend_vm_execute.skl is a skeleton of the executor with placeholders where the dispatch code goes. A third file, zend_vm_gen.php, reads both and writes zend_vm_execute.h, which in PHP 8.6 is about 123,700 lines of C. Those generated files are committed to PHP's repository, so you only need to run the generator if you want to change something.
The generator does more than copy the templates: it specialises them. An operand of an instruction can be one of five kinds: a constant baked into the script (CONST), a temporary (TMP), a variable used by reference (VAR), a compiled variable (CV), or nothing at all (UNUSED). Which kind it is, is known when the script is compiled and never changes afterwards. So instead of one handler that checks the operand kinds every time it runs, the generator emits a separate handler per combination that occurs.
This is what the template for ADD looks like:
ZEND_VM_HOT_NOCONSTCONST_HANDLER(1, ZEND_ADD, CONST|TMPVARCV, CONST|TMPVARCV) { USE_OPLINE zval *op1, *op2, *result; double d1, d2; op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R); op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R); if (ZEND_VM_SPEC && OP1_TYPE == IS_CONST && OP2_TYPE == IS_CONST) { /* pass */ } else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) { if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) { result = EX_VAR(opline->result.var); fast_long_add_function(result, op1, op2); ZEND_VM_NEXT_OPCODE(); } /* ... */ } /* ... */ ZEND_VM_DISPATCH_TO_HELPER(zend_add_helper, op_1, op1, op_2, op2); }
And this is one of the handlers the generator produces from it:
static ZEND_VM_HOT ZEND_OPCODE_HANDLER_RET ZEND_OPCODE_HANDLER_FUNC_CCONV ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { USE_OPLINE zval *op1, *op2, *result; double d1, d2; op1 = EX_VAR(opline->op1.var); op2 = EX_VAR(opline->op2.var); if (1 && (IS_TMP_VAR|IS_VAR|IS_CV) == IS_CONST && (IS_TMP_VAR|IS_VAR|IS_CV) == IS_CONST) { /* pass */ } else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) { if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) { result = EX_VAR(opline->result.var); fast_long_add_function(result, op1, op2); ZEND_VM_NEXT_OPCODE(); } /* ... */ } /* ... */ ZEND_VM_DISPATCH_TO_HELPER(zend_add_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU_EX op1, op2)); }
The abstract operand accessors have been replaced by concrete ones, and the check for two constant operands has collapsed into a comparison of constants that the C compiler will fold away. The roughly 290 templates in zend_vm_def.h turn into 987 handler functions this way. PHP 5.1, the release that introduced the generator, produced 833 of them, so the amount of specialisation has grown fairly gently over twenty years. For commutative operations such as ADD, the engine additionally swaps the two operands when it assigns the handler, so that only half of the combinations need a handler at all.
The table that maps an instruction to its handler has about 3,500 entries, one per opcode and operand-kind combination, and many of them point at the same handler.
The skeleton contains a placeholder for the dispatch code, and the generator can fill that placeholder in five different ways.
CALL: one function call per instruction
CALL is the simplest of the five. Every handler is an ordinary C function, and handler is a pointer to one of them. The executor is a loop that calls the handler and takes the address of the next instruction from its return value:
while (1) { opline = (opline->handler)(execute_data, opline); if (UNEXPECTED(((uintptr_t)opline & ZEND_VM_ENTER_BIT))) { opline = (const zend_op*)((uintptr_t)opline & ~ZEND_VM_ENTER_BIT); if (EXPECTED(opline != NULL)) { execute_data = EG(current_execute_data); ZEND_VM_LOOP_INTERRUPT_CHECK(); } else { return; } } }
A handler ends by returning the address of the instruction to perform next:
#define ZEND_OPCODE_HANDLER_RET const zend_op * #define ZEND_VM_CONTINUE() return opline #define ZEND_VM_ENTER_BIT 1ULL #define ZEND_VM_ENTER_EX() return (zend_op*)((uintptr_t)opline | ZEND_VM_ENTER_BIT) #define ZEND_VM_RETURN() return (const zend_op*)ZEND_VM_ENTER_BIT
There is a small trick in there that I like. Sometimes a handler does not just want to say "here is the next instruction". It also has to be able to say "I have entered a new call frame", "I have left one", or "we are done". Instead of a second return value, those cases are signalled by setting the lowest bit of the returned pointer. A zend_op is aligned in memory, so the lowest bit of a valid pointer to one is always zero and is free to be used as a flag.
CALL needs nothing but a standard C compiler, which makes it the model that works everywhere. It also costs the most: for every single opcode there is an indirect call, a return, and a loop condition. And because that call is always made from the same place in the code, every opcode in every script shares a single entry in the branch predictor, which is the worst case described above.
SWITCH: one very large switch statement
The next model gets rid of the function calls by pasting the body of every handler into the executor itself, as a case of one switch statement. handler is then not a pointer but a number: the case label to jump to.
#define ZEND_VM_CONTINUE() goto zend_vm_continue while (1) { zend_vm_continue: dispatch_handler = OPLINE->handler; zend_vm_dispatch: switch ((int)(uintptr_t)dispatch_handler) { /* ... */ case 311: /* ZEND_ADD_SPEC_TMPVARCV_TMPVARCV */ { /* the whole body of the ADD handler, pasted in here */ } /* ... */ } }
No calls, no returns, no saving and restoring of registers, and all handlers can share the same local variables. In exchange, the executor becomes a single function containing the bodies of a thousand handlers, which is more than most C compilers can optimise well. And all dispatch still goes through one jump, the one the switch compiles to.
SWITCH needs nothing beyond standard C, just like CALL, and it is never selected automatically.
switch compiles to a jump.
GOTO: jumping straight into the next handler
This one needs a C extension that GCC introduced and other compilers adopted: labels as values. Standard C can only jump to a label that is written into the code. GCC lets you take the address of a label with && and jump to an address held in a variable:
void *target = &&arrived; /* take the address of a label */ goto *target; /* jump to the address in a variable */ arrived: printf("here we are\n");
This is called a computed goto, and it compiles to a single indirect jump instruction. With it, handler can hold the address of the label at which a handler's code begins, and dispatch becomes one jump:
#define ZEND_VM_CONTINUE() goto *(void**)(OPLINE->handler) while (1) { goto *(void**)(OPLINE->handler); { /* ... */ ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL: { /* the whole body of the ADD handler, pasted in here */ } /* ... */ } }
The handler bodies are still pasted into one enormous function, as in SWITCH, but the crucial difference is where the jump is. Each handler ends with its own goto *, at its own address in memory. Each of those gets its own entry in the CPU's branch predictor, and the predictor can learn the patterns that actually occur in real PHP code. The while (1) that surrounds all of this is a formality; control never returns to it.
This design is known as a direct threaded interpreter. Like SWITCH, GOTO is never selected automatically.
goto *. Each of these jumps gets its own history in the branch predictor, which can then learn the patterns that occur in real PHP code.
HYBRID: separate functions plus computed goto
HYBRID is what most PHP binaries on Linux actually use, and it combines the two previous ideas. Handlers stay separate C functions, as in CALL, so the compiler can optimise each of them on its own. But the executor is not a loop that calls them and waits for a return value. It is a table of labels, one per handler, and each label calls its handler and then computed-gotos to the next label:
#define HYBRID_NEXT() HYBRID_JIT_GUARD(); goto *(void**)(OPLINE->handler) #define HYBRID_SWITCH() HYBRID_NEXT(); #define HYBRID_CASE(op) op ## _LABEL #define HYBRID_BREAK() HYBRID_NEXT()
while (1) { HYBRID_SWITCH() { /* ... */ HYBRID_CASE(ZEND_ADD_SPEC_TMPVARCV_TMPVARCV): ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU); HYBRID_BREAK(); /* ... */ HYBRID_CASE(HYBRID_HALT): /* restore the saved registers and return from execute_ex() */ } }
So the loop is again a formality: the jump from one label to the next never passes through the top of the loop. Handlers that are marked as hot in zend_vm_def.h get the attribute zend_always_inline in this model, which means the C compiler pastes their body into the label instead of emitting a call. For the most frequent operations there is then no function call left at all, just the handler's code followed by its own goto *.
HYBRID goes one step further and pins the two hottest variables into fixed CPU registers, using another GCC extension called global register variables:
#if defined(__GNUC__) && ZEND_GCC_VERSION >= 4008 && defined(__x86_64__) # define ZEND_VM_FP_GLOBAL_REG "%r14" # define ZEND_VM_IP_GLOBAL_REG "%r15" #endif register zend_execute_data* volatile execute_data __asm__(ZEND_VM_FP_GLOBAL_REG); register const zend_op* volatile opline __asm__(ZEND_VM_IP_GLOBAL_REG);
execute_data and opline now live permanently in two registers of the processor, for the whole lifetime of the process. They never have to be loaded from memory, never have to be written back and never have to be passed as arguments, which is why handler functions in this model take no arguments at all and return nothing. A handler updates the register and returns; the label it was called from then jumps to wherever that register now points.
The registers are chosen per architecture, and they are callee-saved ones so that they survive calls into ordinary C functions. On x86-64 they are %r14 and %r15, on ARM64 x27 and x28.
One detail that made me smile: to fill the dispatch table, the addresses of all those labels have to be collected, and you can only take the address of a label from inside the function that contains it. So the engine calls execute_ex() once, at startup, with a null pointer instead of a call frame. That call takes a special branch that does nothing but write down where every label is:
if (UNEXPECTED(execute_data == NULL)) { static zend_vm_opcode_handler_t const labels[] = { (void*)&&ZEND_NOP_SPEC_LABEL, (void*)&&ZEND_ADD_SPEC_CONST_CONST_LABEL, (void*)&&ZEND_ADD_SPEC_CONST_TMPVARCV_LABEL, /* ... about 3,500 entries in total ... */ }; zend_opcode_handlers = (zend_vm_opcode_handler_t*) labels; zend_handlers_count = sizeof(labels) / sizeof(labels[0]); memset(&hybrid_halt_op, 0, sizeof(hybrid_halt_op)); hybrid_halt_op.handler = (void*)&&HYBRID_HALT_LABEL; /* ... */ goto HYBRID_HALT_LABEL; }
Stopping works through the same mechanism. hybrid_halt_op is a fake instruction whose handler is the address of the label that returns from execute_ex(). To stop, a handler points opline at that fake instruction and returns; the next dispatch then jumps to the exit.
HYBRID needs GCC with both computed gotos and global register variables. Where that is available, it is the fastest of the five.
zend_vm_def.h is inlined into the label, so that no call is left for the most common operations.
TAILCALL: handlers that jump to each other
The newest model, added in PHP 8.5, gets to roughly the same place as HYBRID without needing GCC. It exists because Clang supports the computed-goto syntax but not global register variables, so Clang-built PHP binaries used to fall back to CALL and were measurably slower.
The idea rests on a concept called a tail call: a function call that is the very last thing a function does before returning. When a compiler sees one, it may reuse the current stack frame instead of allocating a new one, and turn the call into a plain jump. Nothing is left to do after the jump, so there is nothing to return to. A chain of a billion tail calls uses as much stack as a single one.
In TAILCALL, every handler is again an ordinary function, as in CALL, but instead of returning to a loop, it tail-calls the handler of the next instruction:
#define ZEND_VM_TAIL_CALL(call) ZEND_MUSTTAIL return call #define ZEND_VM_CONTINUE() ZEND_VM_TAIL_CALL(opline->handler(execute_data, opline)) #define ZEND_VM_RETURN() opline = &call_halt_op; ZEND_VM_CONTINUE()
ZEND_MUSTTAIL is the musttail attribute, and the "must" is essential. A compiler is allowed to perform tail-call optimisation but it is not obliged to. If it declined here, the stack would grow by one frame per opcode and any non-trivial script would run out of stack. musttail turns "you may" into "you must, or refuse to compile".
The second ingredient is a calling convention called preserve_none. In it, the caller declares that it has nothing worth preserving in any register, so the called function may use nearly all registers without saving and restoring anything. For a chain of handlers that never return, that fits well. It has a second effect that matters even more: preserve_none passes its first arguments in registers that are not clobbered across calls, so execute_data and opline stay in the same two registers from handler to handler, and no code is needed to move them along. That is the same benefit HYBRID gets from pinning registers, achieved through a calling convention instead of a compiler extension.
Stopping again works through a fake instruction: call_halt_op, whose handler is a function that simply returns.
The commit that introduced TAILCALL notes that before it, binaries built with Clang were between 2.8 % and 44 % slower than GCC-built ones, depending on the benchmark. Afterwards they are on par.
execute_data and opline stay in the same registers, because preserve_none passes them there.
Which model does your PHP use?
Since PHP 8.5 there is a constant for it:
$ php -r 'echo ZEND_VM_KIND, PHP_EOL;' ZEND_VM_KIND_HYBRID
The choice is made when PHP is compiled, and it is made by the C preprocessor from what the build system found out about the compiler:
#if 0 /* HYBRID requires support for computed GOTO and global register variables*/ #elif (defined(__GNUC__) && defined(HAVE_GCC_GLOBAL_REGS)) # define ZEND_VM_KIND ZEND_VM_KIND_HYBRID #elif defined(HAVE_MUSTTAIL) && defined(HAVE_PRESERVE_NONE) && (defined(__x86_64__) || defined(_M_X64) || defined(__aarch64__)) # define ZEND_VM_KIND ZEND_VM_KIND_TAILCALL #else # define ZEND_VM_KIND ZEND_VM_KIND_CALL #endif
Those three HAVE_ names come from probes the build system runs before compiling anything. HAVE_GCC_GLOBAL_REGS is set if a small test program that uses global register variables compiles and runs. HAVE_MUSTTAIL is set if the compiler knows the musttail attribute. HAVE_PRESERVE_NONE is the most thorough of the three: it compiles and runs a program that checks not only that preserve_none is understood, but that the compiler puts the first two arguments and the return value in exactly the registers the JIT expects. In practice, that combination means Clang 19 or newer on x86-64 or ARM64. If neither branch applies, CALL is used. SWITCH and GOTO never appear in this cascade: they cannot be selected by building PHP differently, only by regenerating the executor.
That regeneration is a step you run before compiling PHP:
$ cd Zend $ php zend_vm_gen.php --with-vm-kind=SWITCH
The accepted values are CALL, SWITCH, GOTO and HYBRID, with HYBRID as the default. TAILCALL is deliberately not among them: it is not a separate generator mode but a variant that the generator emits alongside CALL and HYBRID, guarded by the cascade above. That is why a header generated with --with-vm-kind=SWITCH gives you SWITCH and nothing else.
Two more generator options are worth knowing about if you ever go looking: --without-specializer turns off the operand specialisation described earlier, which makes the generated file far smaller and far slower; --with-lines emits #line directives so that a debugger shows you the template in zend_vm_def.h instead of the generated code.
Since handler means something different in each model, its C type changes with the model as well:
#if ZEND_VM_KIND == ZEND_VM_KIND_HYBRID typedef const void* zend_vm_opcode_handler_t; #elif ZEND_VM_KIND == ZEND_VM_KIND_CALL || ZEND_VM_KIND == ZEND_VM_KIND_TAILCALL typedef const struct _zend_op *(ZEND_OPCODE_HANDLER_CCONV *zend_vm_opcode_handler_t)(struct _zend_execute_data *execute_data, const struct _zend_op *opline); #elif ZEND_VM_KIND == ZEND_VM_KIND_SWITCH typedef int zend_vm_opcode_handler_t; #elif ZEND_VM_KIND == ZEND_VM_KIND_GOTO typedef const void* zend_vm_opcode_handler_t; #endif
What each model is for
This was the question I actually started with, and the answer turned out to be less exotic than I expected. Three of the models are in production use and two are not.
| Model | Dispatch | Handlers are | Used for |
|---|---|---|---|
| CALL | indirect call from a loop | separate functions | Windows builds and every other toolchain |
| SWITCH |
one switch statement
|
inlined cases | reference and benchmarking only |
| GOTO |
computed goto
|
inlined labelled blocks | reference and benchmarking only |
| HYBRID |
computed goto plus call
|
separate functions | GCC builds, so most Linux packages |
| TAILCALL | guaranteed tail call | separate functions | Clang builds on x86-64 and ARM64 |
CALL is the portability floor. Windows binaries are built with Microsoft's compiler, which has neither computed gotos nor global register variables nor preserve_none, so this is what you get there. It is also the model that is easiest to work with: every handler is a normal function, so a profiler shows you handler names and a debugger can put a breakpoint in one.
HYBRID is what a Linux distribution's PHP package uses, because those are built with GCC. TAILCALL is what you get when building with a recent Clang on x86-64 or ARM64, which covers macOS and FreeBSD, where Clang is the default toolchain. Those two are where the performance work happens.
SWITCH and GOTO are the two that are not selected by any build. They are kept because they are useful to compare against, and because they document the design space. If you want to know what direct threading buys over a switch, you can build both and measure. That they are not production paths is stated rather bluntly in OPcache, which refuses to even compile against them:
#if ZEND_VM_KIND != ZEND_VM_KIND_CALL && ZEND_VM_KIND != ZEND_VM_KIND_TAILCALL && ZEND_VM_KIND != ZEND_VM_KIND_HYBRID # error JIT is compatible only with CALL and HYBRID VM #endif
The message has not quite kept up with the condition, since TAILCALL is allowed as well, but the point stands: with a SWITCH or GOTO executor, OPcache does not build.
Which brings up the JIT. It sits next to all of this rather than replacing it: OPcache can compile some functions to machine code, and those functions then no longer go through the dispatch loop at all. Everything that is not compiled still does, which is why the executor keeps mattering even with the JIT enabled. The two are closely entangled, too: in HYBRID mode the JIT jumps directly to the handler labels inside execute_ex(), which is why there is a macro in the dispatch path whose only job is to keep certain registers untouched for the JIT's benefit.
What I took away
The executor rests on a very small idea: look at an instruction, do it, go to the next one. Decades of work have gone into that last step. All five models perform the same instructions in the same order and produce the same results. They differ only in how control gets from one instruction to the next, and on some workloads that difference is worth a double-digit percentage.
None of this changes how any of us write PHP. It did change what I pay attention to when someone shows me a benchmark. The compiler used to build a PHP binary is a performance decision, and until PHP 8.5 it was a fairly large one. If you compare numbers between two PHP installations, ZEND_VM_KIND belongs in the comparison alongside the version number and the relevant INI settings.
The other thing I keep coming back to is the generator. A core piece of the Zend Engine is produced by a PHP script, from templates, into a hundred thousand lines of C that nobody reads in one go. That is what makes it practical to keep five executors around, hold them to the same behaviour, and try out a sixth idea without hand-writing a thousand handlers.