Why PHP Is Slower on Windows
Status: MEASURED + SOURCE-VERIFIED. Unlike the modelled Performance Comparison page, every number here is either our own measurement or an upstream-published benchmark, and every mechanism claim was checked against php-src source. Claims are tagged:
- [MEASURED] — our benchmarks (Ryzen 9 5950X, 128 GB, NVMe, same silicon dual-boot, same app, same ini, PHP 8.5.x).
- [VERIFIED] — checked directly against php-src (branch
PHP-8.5, fetched 2026-08) or an authoritative upstream document, with a citation.- [INFERRED] — follows from verified facts; the final step is our reasoning.
- [JUDGEMENT] — our interpretation; reasonable people could weight it differently.
For the practical takeaways, see the companion guide: Getting the Most from PHP on a Windows Box.
The one-paragraph answer: Windows PHP is built with MSVC, and MSVC
cannot compile the Zend VM’s fast interpreter. The interpreter falls back
from the GCC-only “HYBRID” dispatch (computed goto + execute_data/opline
pinned in CPU registers) to the portable “CALL” dispatch (one indirect
function call per opcode). Upstream’s own numbers put CALL at ~1.77x slower
than HYBRID on CPU-bound code — which is most of our measured 2.0–2.2x
interpreter gap; the remainder is MSVC-vs-GCC codegen and build-configuration
differences. Independently of the interpreter, Windows filesystem metadata
operations (stat/file_exists) cost roughly an order of magnitude more
than on Linux, because each one goes through per-call UTF-8→UTF-16
conversion, path canonicalization, a CreateFileW handle open, and the NT
filter-driver stack. Real applications see a blend of both effects. Windows
Defender, ZTS, and “no opcache on Windows” do not explain the gap.
1. The measured facts [MEASURED]
All numbers ours, PHP 8.5.x, identical hardware, identical script, identical
ini (opcache on, opcache.jit=disable on every arm — deliberately, to
isolate the interpreter):
Pure opcode execution (CPU-only loop: 300k integer ops + 20k string appends, zero filesystem):
| Build | OS | Dispatch (see §2) | Time |
|---|---|---|---|
| ePHPm-embedded 8.5.7 ZTS (through our SAPI, serve mode, c=1) | Windows | CALL | 5.57 ms |
| ePHPm-embedded 8.5.7 ZTS (through same SAPI) | Linux | HYBRID | 2.55 ms |
| php.net 8.5.9 NTS, CLI | Windows | CALL | 6.39 ms |
| FrankenPHP-bundled 8.5.9 ZTS, CLI | Windows | CALL | 6.29 ms |
| ePHPm-embedded 8.5.7 ZTS, CLI | Windows | CALL | 4.97 ms |
| ePHPm-embedded 8.5.7 ZTS, CLI | Linux | HYBRID | 2.44 ms |
| Ubuntu distro 8.5.4 NTS, CLI | Linux | HYBRID | 2.17 ms |
→ Pure interpretation is ~2.0–2.2x slower on Windows, consistently, across four independent Windows builds from three build pipelines.
Filesystem metadata (recursive dir walk + 2,000 file_exists() over
vendor/symfony): 207.6 ms Windows vs 20.4 ms Linux → ~10x on this
workload shape.
Windows Defender folder exclusion: recovered only ~2.8% throughput → Defender is not the story.
Real app (Symfony demo, dev mode): 5–7x slower on Windows — consistent with a mix of the ~2x CPU factor and the ~10x metadata factor (dev mode stats aggressively).
Interesting datum: our own embedded build is ~22% faster than php.net’s official Windows build on the CPU loop — yet still ~2x behind Linux. §2.4 takes this apart.
2. The interpreter half: MSVC gets a slower virtual machine
2.1 The Zend VM’s dispatch kinds [VERIFIED]
The interpreter loop is generated by
Zend/zend_vm_gen.php
into Zend/zend_vm_execute.h. The shipped, pre-generated file contains the
code for CALL, HYBRID, and (8.5+) TAILCALL, selected by the preprocessor at
compile time. From
Zend/zend_vm_opcodes.h
(lines 28–49), verbatim:
#define ZEND_VM_KIND_CALL 1
#define ZEND_VM_KIND_SWITCH 2
#define ZEND_VM_KIND_GOTO 3
#define ZEND_VM_KIND_HYBRID 4
#define ZEND_VM_KIND_TAILCALL 5
...
#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(__aarch64__)) && defined(__clang__)
# define ZEND_VM_KIND ZEND_VM_KIND_TAILCALL
#else
# define ZEND_VM_KIND ZEND_VM_KIND_CALL
#endif- HYBRID (default on GCC since PHP 7.2): opcode handlers are code blocks
inside one giant function; dispatch is a computed goto —
goto *(void**)(OPLINE->handler)— and the two hottest interpreter variables are pinned into CPU registers for the entire process:execute_datain%r14,oplinein%r15on x86-64, via GCC global register variables (Zend/zend_execute.c, theZEND_VM_FP_GLOBAL_REG/ZEND_VM_IP_GLOBAL_REGchain). - CALL: every opcode is a separate function; the loop does one indirect
callper opcode; each handler pushes/pops callee-saved registers and re-loadsoplinefrom memory. Upstream’s tail-call VM PR #17849 documents the overhead explicitly, showing thepush %rbp / push %r15 / push %r14 / push %rbxprologue every handler pays. - TAILCALL (new in PHP 8.5): handlers tail-call the next handler via
[[clang::musttail]]using thepreserve_nonecalling convention — Clang-only. - SWITCH, GOTO: legacy generator modes, not shipped pre-generated.
2.2 What Windows/MSVC actually gets: CALL [VERIFIED]
HAVE_GCC_GLOBAL_REGS is defined only by an autoconf probe —
Zend/Zend.m4,
ZEND_CHECK_GLOBAL_REGISTER_VARIABLES — which requires GCC ≥ 4.8 on a
supported architecture. The Windows build system (win32/build/config.w32,
confutils.js) contains no equivalent probe and never defines
HAVE_GCC_GLOBAL_REGS — we grepped the full files. MSVC also does not
define __GNUC__, has no labels-as-values, and has no global register
variables. Both #elif arms above therefore fail on MSVC, and every MSVC
build of PHP — php.net official, FrankenPHP-bundled, and our own SDK —
compiles the CALL VM. There is no build flag that changes this; it is a
compiler capability, not a configuration.
Two corroborations:
- PHP’s official Windows build docs: “PHP officially supports building with Microsoft’s Visual C++ compilers. MinGW and Cygwin are not supported; ICC and clang can be used for experimental purposes” (wiki.php.net step-by-step build). So GCC — the only compiler that produces HYBRID — is not a supported Windows toolchain at all.
- Clang never qualifies for HYBRID either: clang masquerades as GCC 4.2, failing the ≥ 4.8 probe — which is exactly why upstream describes the fast VM as GCC-only and built TAILCALL for Clang (PR #17849: “enabled when compiling with GCC, so this will not improve performances with this compiler, but it makes PHP on Clang as fast as on GCC”). [VERIFIED source mechanism; the version arithmetic is INFERRED from clang’s well-known GCC-4.2 masquerade.]
FrankenPHP’s Windows distribution “links directly against the official, stable PHP binaries provided by the PHP project” (Dunglas, March 2026) — i.e., the same MSVC CALL-VM interpreter as php.net’s zip. That is why our FrankenPHP (6.29 ms) and php.net (6.39 ms) numbers are near-identical: same interpreter binary lineage. [VERIFIED + MEASURED]
2.3 What the dispatch difference is worth, per upstream [VERIFIED]
- Dmitry Stogov, introducing HYBRID (php-internals, May 2017): “significant performance improvement on small benchmarks (1.5 times faster on bench.php)” and “slight improvement on real-life apps (1-2% on wordpress)” (externals.io/message/99043).
- Arnaud Le Blanc’s tail-call VM PR (merged for 8.5) benchmarked
GCC-HYBRID vs Clang-CALL vs Clang-TAILCALL on the same machine,
Zend/bench.php: 1.006 s (HYBRID) vs 1.783 s (CALL) vs 1.017 s (TAILCALL) — i.e. the CALL VM is 1.77x slower than HYBRID on opcode-bound code, and TAILCALL recovers essentially all of it (within 1%). On Symfony Demo the CALL penalty was +5.5% end-to-end. (php-src PR #17849)
Two crucial calibrations fall out of the upstream numbers:
- Our 2.2x on a CPU-only microloop is the expected shape. 1.77x of it is the dispatch kind alone (measured by upstream on Linux, same OS both sides — so it is a pure compiler/dispatch effect, not a Windows effect). The residual ~15–25% is consistent with MSVC-vs-GCC codegen quality on interpreter-style code, ZTS/CRT differences, and build flags (§2.4). [INFERRED]
- Dispatch does not explain real-app gaps. Upstream saw only 1–5% end-to-end impact from CALL vs HYBRID on WordPress/Symfony. Our 5–7x Symfony-demo gap on Windows must therefore be dominated by the filesystem half (§3) plus per-request OS costs, with the interpreter contributing a bounded ~1.1–2x factor depending on how CPU-bound the request is. [INFERRED]
2.4 Build-process differences: the other ~20% [VERIFIED facts, JUDGEMENT attribution]
- Official php.net Windows builds ARE PGO-optimized. The official
pipeline (php/php-windows-builder)
does the full cycle: build with
--enable-pgi→phpsdk_pgo --train(web-app-shaped scenarios) → rebuild with--with-pgo. Microsoft’s SDK tooling claims PGO “can give an overall speedup up to 30%” (microsoft/php-sdk-binary-tools). - Linux side: distro PHP is GCC
-O2(Debian/Ubuntu do not PGO php as of our check). So Linux’s lead is not a PGO lead — Linux wins on dispatch + codegen while typically having less build-time profile optimization than php.net’s Windows zips. [JUDGEMENT on distro flags: spot-checked, not exhaustively audited.] - Our 22% spread (ePHPm 4.97 ms vs php.net 6.39 ms, both Windows CALL
VMs): our SDK is a static monolith built by static-php-cli on VS2022,
without PGO — and still faster than php.net’s PGO’d build. Plausible
contributors, all [JUDGEMENT]: (a) php.net’s CLI calls into
php8.dllthrough import thunks with no cross-module inlining, while our build is statically linked; (b) PGO trained on web scenarios can do nothing for an untrained CPU loop; (c) toolchain minor-version differences.
Layer-separation: the compiler’s dispatch kind explains Windows-vs-Linux; build/link/flag differences explain Windows-vs-Windows. The 22% spread is empirical proof that ~20%-scale build effects are real while remaining far too small to bridge the 2x to Linux.
2.5 How the TAILCALL VM works — three clarifications [VERIFIED]
TAILCALL comes up often enough that three points are worth stating plainly, because each is a common misconception:
The VM kind is chosen at compile time, not by a runtime setting. The selection above (
ZEND_VM_KIND_CALL/HYBRID/TAILCALL/ …) is resolved by the C preprocessor when php-src is built — fromZend/zend_vm_opcodes.hand theZend/zend_vm_gen.phpgenerator. There is noopcache.vm_kind, nophp.initoggle, and no CLI flag that switches it: which VM a binary runs is baked in by how it was compiled. That is exactly why ePHPm ships a separate-tailcallWindows binary (built with clang-cl) rather than a config option — you cannot turn TAILCALL “on” in a running MSVC build. [VERIFIED — the §2.1 selection logic is preprocessor-only]TAILCALL is shipped, not a future feature — but only in PHP 8.5, only under Clang. It landed in PHP 8.5 (PR #17849) and requires Clang’s
[[clang::musttail]](HAVE_MUSTTAIL) plus thepreserve_nonecalling convention (HAVE_PRESERVE_NONE) on x86-64/aarch64. It does not exist in PHP 8.3 or 8.4, and it never compiles under MSVC or GCC. So ePHPm’s-tailcallartifact is PHP-8.5-only by construction, not by policy. [VERIFIED]Every platform already runs the fastest interpreter its compiler can emit — TAILCALL is how Clang platforms reach that bar, not a project-wide switch. On Linux, GCC produces HYBRID, and HYBRID is already as fast as TAILCALL: upstream’s own
bench.phphas them within noise, HYBRID marginally ahead (1.006 s HYBRID vs 1.017 s TAILCALL, PR #17849). Switching Linux to TAILCALL would therefore gain nothing and cost the GCC toolchain (HYBRID’s register pinning is GCC-only) — there is no “make everything TAILCALL for cohesion” upside. TAILCALL exists for the platforms GCC cannot serve: Clang-only targets, where the fallback is the slow CALL VM. Windows/MSVC is exactly such a platform, which is why the-tailcall(clang-cl) build is a Windows artifact and not a Linux one. (macOS builds with Clang too: on PHP 8.5 it already picks up TAILCALL, so a variant would add nothing; on 8.3/8.4 it falls back to the same slow CALL VM as MSVC — clang cannot emit HYBRID and TAILCALL does not exist there — so no faster macOS variant is possible to publish. Use macOS 8.5 for CPU-bound work.) [VERIFIED bench; JUDGEMENT on the toolchain trade-off]
3. The filesystem half: why metadata ops cost ~10x
Scope honesty first: we measured 10x on one workload shape (deep
vendor/ walk + 2,000 file_exists()). The multiplier varies with
directory depth, cache warmth, and filter load; treat “roughly an order of
magnitude for stat-heavy work” as the claim, not a universal constant.
[MEASURED + JUDGEMENT]
3.1 What one file_exists() costs on Windows [VERIFIED against win32/ioutil.c]
PHP routes Win32 filesystem calls through its ioutil layer
(win32/ioutil.c).
Per call:
- UTF-8 → UTF-16 conversion, with a heap allocation —
php_win32_cp_conv_any_to_w()converts the path for every wide API call, allocating and freeing per invocation. - Path canonicalization —
php_win32_ioutil_normalize_path_w()viaPathCchCanonicalizeEx(), adding the\\?\long-path prefix when needed. - A handle-based stat —
php_win32_ioutil_stat_ex_w()opens the file withCreateFileW(FILE_READ_ATTRIBUTES)and callsGetFileInformationByHandle(), falling back toGetFileAttributesExW()when the open fails. ACreateFileWis a full NT object-manager open: ACL check, name resolution, and a pass through every registered filter driver — twice, counting the close. - Reparse-point handling — if the target is a reparse point, an
additional handle is opened with
FILE_FLAG_OPEN_REPARSE_POINTandDeviceIoControl(FSCTL_GET_REPARSE_POINT)is issued to classify symlinks.
The Linux equivalent is a single statx()/access() syscall served
overwhelmingly from the dentry cache. The structural asymmetry
(handle-open semantics + filter stack + per-call conversion vs one cached
syscall) is what PHP’s usage pattern — frameworks statting hundreds of files
per request — multiplies. [VERIFIED chain; the “overwhelmingly dentry cache”
characterization is standard-kernel-behavior, INFERRED for our specific
workload.]
3.2 The filter stack is an OS-level tax that Defender exclusions don’t remove [VERIFIED + MEASURED]
Excluding a folder from Defender only skips scanning; the filter drivers (Defender’s and others’) remain attached to the volume and still see every I/O request packet. Microsoft’s own answer to this is Dev Drive: a ReFS volume that attaches only a minimal set of filter drivers, which Microsoft credits with “up to 30% better performance for overall build times” on file-heavy developer workloads (Dev Drive docs). That Microsoft ships a special volume type to shed filter overhead is the cleanest available evidence that the overhead is real, acknowledged, and not Defender-exclusion-fixable — matching our measured ~2.8% recovery from an exclusion.
3.3 PHP’s mitigations and their limits [VERIFIED]
- Realpath cache: default
realpath_cache_size=4M,realpath_cache_ttl=120seconds (php.net core ini docs). It absorbs repeated path resolution — but it is per-process (per-thread under ZTS [INFERRED from TSRM globals design]), TTL-expiring, and completely disabled byopen_basedir(documented: “Using open_basedir will disable the realpath cache”). ePHPm’s serve mode raises it to16Mby default ([php] realpath_cache_size). - opcache with
opcache.validate_timestamps=0eliminates per-include stat storms entirely — the standard production answer, and ePHPm’sephpm servedefault. Limits: userlandfile_exists()/is_file()/filemtime()still hit the OS, and dev workflows generally can’t run with validation off.
4. What does NOT explain the gap
- Windows Defender. ~2.8% from a folder exclusion on our metadata benchmark. [MEASURED] Real, worth doing, irrelevant to a 10x.
- ZTS vs NTS. php.net NTS 6.39 ms ≈ FrankenPHP ZTS 6.29 ms on the same loop. [MEASURED] Thread-safety indirection is noise here (and those two share interpreter lineage — §2.2 — making this a clean A/B). The Linux side agrees: Ubuntu NTS 2.17 vs our ZTS 2.44 ms ≈ 11%, the conventional ZTS cost, an order of magnitude too small to matter. [MEASURED + JUDGEMENT]
- “Windows PHP is slow because opcache/JIT doesn’t work there.” Dead.
opcache runs fine on Windows; all our arms ran with identical
opcache-enabled ini. The one true sliver — OPcache’s
supported_sapisallowlist rejecting unknown embed SAPIs — was removed in PHP 8.5 (the list is gone from current php-src; our SDK patches it into 8.3/8.4 builds), so embedded runtimes get opcache too. In ePHPm’s release builds opcache is statically compiled in and enabled with no ini file at all. [VERIFIED + our infra] zend_signalabsence / timer implementation. On Windows, execution timeout uses a timer-queue timer (CreateTimerQueueTimerinZend/zend_execute_API.c) armed once per request, and the VM polls the same atomictimed_outflag on both platforms — nothing per-opcode differs. Noise. [VERIFIED]win32/time.cgettimeofday emulation. Historically a scandal, currently a thin wrapper overGetSystemTimePreciseAsFileTime()— cheap, and not on the opcode path anyway. Retired concern. [VERIFIED]
5. What closes the gap, and by how much [MEASURED]
The interpreter half has two real fixes — the TAILCALL VM (§2.5) and the JIT — and after v0.7.3 shipped we re-measured both end-to-end through the released Windows binaries (serve mode, c=1, matched opcache ini, quiet box, 100% HTTP 200). The result is more nuanced than a flat “TAILCALL is ~1.7x”. (The v0.7.3 release notes quote the JIT-off interpreter figure, ~1.7x; the JIT-on picture below is the fuller story.)
| Arm (released v0.7.3 binaries) | MSVC (CALL) | TAILCALL | TAILCALL vs MSVC |
|---|---|---|---|
| Interpreter only, JIT off — CPU loop | 4.795 ms | 2.791 ms | 1.72x faster |
Warm hot loop, JIT on (opcache.jit=tracing) | 1.835 ms | 1.976 ms | gap closed — MSVC edges ahead |
Cold / short code, JIT on (cpu.php, c=1) | 5.33 ms | 3.44 ms | 1.55x faster |
Read those three rows as one story:
- JIT off → TAILCALL’s 1.72x is the full, durable win. This is the pure
interpreter number, and it is exactly what runs whenever the JIT is off:
in ePHPm’s multi-tenant serve mode (where the JIT is disabled by
default — per-vhost
opcache_invalidatenever reclaims JIT buffer, #350), in worker mode, in dev, and anywhere an operator setsopcache_jit = "disable". Multi-tenant serve is TAILCALL’s best real-world case: there the interpreter is the whole game, so the ~1.72x lands in full. - JIT on, warm hot path → the gap essentially closes. With
opcache.jit=tracing(ePHPm’s single-site serve default since v0.7.3, #350) hot code is compiled to native machine code that never touches the C interpreter’s dispatch — so the host VM’s dispatch quality stops mattering, and MSVC+JIT and TAILCALL+JIT land on top of each other (MSVC even edges ahead, and both beat the ~2.5 ms Linux HYBRID interpreter from §1). Do not read the 1.6–1.7x as a JIT-on single-site hot-path number — it is the interpreter number. - JIT on, cold/short code → TAILCALL still wins (~1.55x). The JIT only
helps code it has already traced and compiled; first-request paths,
framework bootstrap, and short-lived scripts run the interpreter even with
the JIT enabled, and there TAILCALL keeps a real edge (cold
cpu.php: 3.44 ms vs 5.33 ms). TAILCALL’s marginal value in a JIT-on single-site deployment is this cold-path latency, not steady-state throughput. - Neither moves a filesystem-bound app much. On the Symfony demo (JIT
off) TAILCALL was worth +4.9% on
/enand +3.1% on/en/blog, and the app is filesystem-bound either way — ~80 req/s on Windows vs ~580 on Linux ext4, a 5–7x gap that holds regardless of VM, exactly as §2.3’s calibration predicts. The levers for real apps are the filesystem levers (§3.3, and the guide).
What does not work: MinGW-GCC (the only compiler that yields HYBRID) is explicitly unsupported by PHP’s Windows build system, and MSYS2 ships no PHP package at all — we checked both package trees; a MinGW HYBRID build is a porting project, not a configuration. [VERIFIED] MSVC itself has no labels-as-values, no global register variables, and no guaranteed-tail-call attribute, with no roadmap signal — waiting on Microsoft is not a plan. [VERIFIED absence, JUDGEMENT on outlook]
The filesystem half has no build-time fix: it is OS architecture plus PHP’s
per-call conversion layer. You cannot make one stat cheap; you can only
stop paying it thousands of times per request — production opcache settings,
realpath-cache tuning, authoritative autoloader classmaps, Dev Drive.
6. Sources
php-src (branch PHP-8.5):
Zend/zend_vm_opcodes.h
(VM-kind selection) ·
Zend/zend_execute.c
(global register pinning) ·
Zend/Zend.m4
(ZEND_CHECK_GLOBAL_REGISTER_VARIABLES) ·
win32/ioutil.c
(stat/path chain) ·
Zend/zend_execute_API.c
(Windows timeout timer)
Upstream benchmarks and discussion: php-src PR #17849 — tail-call VM (HYBRID 1.006 s / CALL 1.783 s / TAILCALL 1.017 s; Symfony +5.5%) · Stogov, HYBRID announcement · wiki.php.net — Windows build · php/php-windows-builder · microsoft/php-sdk-binary-tools · Dunglas — FrankenPHP on Windows
OS-level: php.net core ini — realpath cache · Dev Drive docs
Ours: benchmark setup in §1; the TAILCALL SDK lane and its measured numbers are tracked in ephpm/ephpm#329.