Skip to content
Php on Windows

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):

BuildOSDispatch (see §2)Time
ePHPm-embedded 8.5.7 ZTS (through our SAPI, serve mode, c=1)WindowsCALL5.57 ms
ePHPm-embedded 8.5.7 ZTS (through same SAPI)LinuxHYBRID2.55 ms
php.net 8.5.9 NTS, CLIWindowsCALL6.39 ms
FrankenPHP-bundled 8.5.9 ZTS, CLIWindowsCALL6.29 ms
ePHPm-embedded 8.5.7 ZTS, CLIWindowsCALL4.97 ms
ePHPm-embedded 8.5.7 ZTS, CLILinuxHYBRID2.44 ms
Ubuntu distro 8.5.4 NTS, CLILinuxHYBRID2.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_data in %r14, opline in %r15 on x86-64, via GCC global register variables (Zend/zend_execute.c, the ZEND_VM_FP_GLOBAL_REG/ZEND_VM_IP_GLOBAL_REG chain).
  • CALL: every opcode is a separate function; the loop does one indirect call per opcode; each handler pushes/pops callee-saved registers and re-loads opline from memory. Upstream’s tail-call VM PR #17849 documents the overhead explicitly, showing the push %rbp / push %r15 / push %r14 / push %rbx prologue every handler pays.
  • TAILCALL (new in PHP 8.5): handlers tail-call the next handler via [[clang::musttail]] using the preserve_none calling 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:

  1. 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]
  2. 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-pgiphpsdk_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.dll through 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:

  1. 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 — from Zend/zend_vm_opcodes.h and the Zend/zend_vm_gen.php generator. There is no opcache.vm_kind, no php.ini toggle, 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 -tailcall Windows 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]

  2. 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 the preserve_none calling 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 -tailcall artifact is PHP-8.5-only by construction, not by policy. [VERIFIED]

  3. 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.php has 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:

  1. UTF-8 → UTF-16 conversion, with a heap allocationphp_win32_cp_conv_any_to_w() converts the path for every wide API call, allocating and freeing per invocation.
  2. Path canonicalizationphp_win32_ioutil_normalize_path_w() via PathCchCanonicalizeEx(), adding the \\?\ long-path prefix when needed.
  3. A handle-based statphp_win32_ioutil_stat_ex_w() opens the file with CreateFileW(FILE_READ_ATTRIBUTES) and calls GetFileInformationByHandle(), falling back to GetFileAttributesExW() when the open fails. A CreateFileW is a full NT object-manager open: ACL check, name resolution, and a pass through every registered filter driver — twice, counting the close.
  4. Reparse-point handling — if the target is a reparse point, an additional handle is opened with FILE_FLAG_OPEN_REPARSE_POINT and DeviceIoControl(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=120 seconds (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 by open_basedir (documented: “Using open_basedir will disable the realpath cache”). ePHPm’s serve mode raises it to 16M by default ([php] realpath_cache_size).
  • opcache with opcache.validate_timestamps=0 eliminates per-include stat storms entirely — the standard production answer, and ePHPm’s ephpm serve default. Limits: userland file_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_sapis allowlist 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_signal absence / timer implementation. On Windows, execution timeout uses a timer-queue timer (CreateTimerQueueTimer in Zend/zend_execute_API.c) armed once per request, and the VM polls the same atomic timed_out flag on both platforms — nothing per-opcode differs. Noise. [VERIFIED]
  • win32/time.c gettimeofday emulation. Historically a scandal, currently a thin wrapper over GetSystemTimePreciseAsFileTime() — 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)TAILCALLTAILCALL vs MSVC
Interpreter only, JIT off — CPU loop4.795 ms2.791 ms1.72x faster
Warm hot loop, JIT on (opcache.jit=tracing)1.835 ms1.976 msgap closed — MSVC edges ahead
Cold / short code, JIT on (cpu.php, c=1)5.33 ms3.44 ms1.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_invalidate never reclaims JIT buffer, #350), in worker mode, in dev, and anywhere an operator sets opcache_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 /en and +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.