Category
Foundation: · performance program, codegen half · child of #36379
Targets (measured by #7 , in the pinned image, geometric mean unless stated)
workload
today (2026-09-01 audit)
v2.0 target
typed recursion / arithmetic (fibo, nbody)
1.1x Zend (regressed from 9x)
≥ 3x faster
untyped calls / loops (simple, closures)
2.5x slower
≤ 1.5x slower
strings / arrays / objects (str-builder, assoc-heavy, object-graph)
10–500x slower or OOM
≤ 1.5x slower
web request (phpc fcgi vs php-fpm)
unmeasured
≥ 1x req/s, ≤ 0.5x memory
startup (hello)
2x faster
keep
Roadmap (each item its own PR series, each gated by #7 and the differential sweeps)
Land and verify wave 1's codegen items on the v2 suite: native-long hot path (Perf: typed int arithmetic boxes every result since overflow promotion — fibo(30) 8x slower than the 2026-07-28 build (lib/JIT/JitLongArithOverflow.php) #36189 ), __value__copy outline (Codegen: every boxed assignment open-codes a 15-block type switch with duplicated error blocks — 111k-line IR module and 51 KB helper functions for a 4-line script (lib/JIT/JitValueBox.php) #36193 ), light pass pipeline (Codegen: no LLVM pass ever runs at the default level — 40 alwaysinline runtime accessors stay real calls, MCJIT never optimises, and the per-function pass manager is mis-wired to a non-existent symbol (lib/JIT/Context.php runModuleOptimizationPasses, vendor/ircmaxell/php-llvm) #36213 — currently crashes every build on old trees, CI: master is unbuildable both from a clean checkout and on already-patched trees — the #36250 guard demands exactly one replaceVariablesByCfgWalk (a correctly patched file has two) and the pass-manager patch no longer lands on old trees, so every AOT build dies in the new light-opt step (script/apply-patches.sh, patches/php-cfg-simplifier-use-chain.patch, patches/php-llvm-module-createfunctionpassmanager.patch) (re-#36250, re-#36213, re-#36229) #36377 ), 16-byte __value__ (Runtime: __value__ is 9 bytes / align 1 so every i64/double payload access is an over-aligned (UB) load, and packed arrays allocate 16 B/slot but stride 9 (lib/JIT/Builtin/Type/Value.php, lib/JIT/Builtin/Type/HashTable.php) #36214 ), string addref (Runtime: every string copy is an eager memcpy — __string__separate ignores the refcount, no copy-on-write (lib/JIT/Builtin/Type/String_.php, lib/JIT/JitValueBox.php) #36192 ), {main} ownership (AOT: every array or string mutation in {main} scope is O(n²) in time and memory — refcounts never decrement in main, so separateContainerForWrite copies the whole table on every store (16k appends: 6.1 s in main vs 0.011 s inside a function) (lib/JIT/Builtin/Refcount.php phpc_destruct_delref_allowed, lib/JIT/HashTableWriteLlvm.php:1694) #36252 ).
Inline caches for property and method access: __object__ slots are compile-time indexed for the declaring class; for polymorphic receivers emit a per-site (class-id → slot/function) cache with a generic fallback (the shape V8/Zend use). Target: object-graph within 1.5x.
Escape analysis for locals: arrays/strings that never escape a function (no return, no store into a container/property/global, no by-ref, no closure capture) become stack allocations with no refcount traffic; Analyzer::canEscape exists for fixed-size int arrays — generalise.
Guarded speculation (Perf: untyped loop counters and untyped user calls run boxed through __value__ with per-op type dispatch — add guarded speculation (unboxed i64 loop phis, unboxed call arguments) with the generic path as bail-out (lib/JIT.php, lib/JIT/JitValueNumeric.php, lib/JIT/Call/Native.php) (re-#23483) #36217 landed tag caching only): unboxed i64/double loop phis with a bail-out arm; specialised clones for hot untyped functions when all call sites agree on argument kinds.
Per-function O1 by default with the function pass manager (Codegen: no LLVM pass ever runs at the default level — 40 alwaysinline runtime accessors stay real calls, MCJIT never optimises, and the per-function pass manager is mis-wired to a non-existent symbol (lib/JIT/Context.php runModuleOptimizationPasses, vendor/ircmaxell/php-llvm) #36213 step 3), whole-module O2 only for release builds; measure build-time cost on Stdlib: binary/octal radix conversion and PHPT specs #9 's gate.
String builder shape: .= in a loop with a refcount-1 destination appends in place (Runtime: .= is O(n²) — every append reallocates and memcpys both sides; no capacity field, no runtime interning (lib/JIT/Builtin/Type/String_.pre concat, lib/JIT/Builtin/Type/String_.php:928) #36216 partial landed geometric growth), implode/sprintf/json_encode write into a sized buffer.
Calling convention: typed params/returns already native; add native ABI for ?int/?string/int|float (tag + payload in two registers) so nullable-typed code stops boxing.
Done when
Category
Foundation:· performance program, codegen half · child of #36379Targets (measured by #7, in the pinned image, geometric mean unless stated)
fibo,nbody)simple, closures)str-builder,assoc-heavy,object-graph)phpc fcgivs php-fpm)Roadmap (each item its own PR series, each gated by #7 and the differential sweeps)
__value__copyoutline (Codegen: every boxed assignment open-codes a 15-block type switch with duplicated error blocks — 111k-line IR module and 51 KB helper functions for a 4-line script (lib/JIT/JitValueBox.php) #36193), light pass pipeline (Codegen: no LLVM pass ever runs at the default level — 40alwaysinlineruntime accessors stay real calls, MCJIT never optimises, and the per-function pass manager is mis-wired to a non-existent symbol (lib/JIT/Context.php runModuleOptimizationPasses, vendor/ircmaxell/php-llvm) #36213 — currently crashes every build on old trees, CI: master is unbuildable both from a clean checkout and on already-patched trees — the #36250 guard demands exactly onereplaceVariablesByCfgWalk(a correctly patched file has two) and the pass-manager patch no longer lands on old trees, so every AOT build dies in the new light-opt step (script/apply-patches.sh, patches/php-cfg-simplifier-use-chain.patch, patches/php-llvm-module-createfunctionpassmanager.patch) (re-#36250, re-#36213, re-#36229) #36377), 16-byte__value__(Runtime:__value__is 9 bytes / align 1 so every i64/double payload access is an over-aligned (UB) load, and packed arrays allocate 16 B/slot but stride 9 (lib/JIT/Builtin/Type/Value.php, lib/JIT/Builtin/Type/HashTable.php) #36214), string addref (Runtime: every string copy is an eager memcpy —__string__separateignores the refcount, no copy-on-write (lib/JIT/Builtin/Type/String_.php, lib/JIT/JitValueBox.php) #36192),{main}ownership (AOT: every array or string mutation in{main}scope is O(n²) in time and memory — refcounts never decrement in main, soseparateContainerForWritecopies the whole table on every store (16k appends: 6.1 s in main vs 0.011 s inside a function) (lib/JIT/Builtin/Refcount.php phpc_destruct_delref_allowed, lib/JIT/HashTableWriteLlvm.php:1694) #36252).__object__slots are compile-time indexed for the declaring class; for polymorphic receivers emit a per-site (class-id → slot/function) cache with a generic fallback (the shape V8/Zend use). Target:object-graphwithin 1.5x.Analyzer::canEscapeexists for fixed-size int arrays — generalise.__value__with per-op type dispatch — add guarded speculation (unboxed i64 loop phis, unboxed call arguments) with the generic path as bail-out (lib/JIT.php, lib/JIT/JitValueNumeric.php, lib/JIT/Call/Native.php) (re-#23483) #36217 landed tag caching only): unboxedi64/doubleloop phis with a bail-out arm; specialised clones for hot untyped functions when all call sites agree on argument kinds.alwaysinlineruntime accessors stay real calls, MCJIT never optimises, and the per-function pass manager is mis-wired to a non-existent symbol (lib/JIT/Context.php runModuleOptimizationPasses, vendor/ircmaxell/php-llvm) #36213 step 3), whole-module O2 only for release builds; measure build-time cost on Stdlib: binary/octal radix conversion and PHPT specs #9's gate..=in a loop with a refcount-1 destination appends in place (Runtime:.=is O(n²) — every append reallocates and memcpys both sides; no capacity field, no runtime interning (lib/JIT/Builtin/Type/String_.pre concat, lib/JIT/Builtin/Type/String_.php:928) #36216 partial landed geometric growth),implode/sprintf/json_encodewrite into a sized buffer.?int/?string/int|float(tag + payload in two registers) so nullable-typed code stops boxing.Done when
docs/pages/bench.htmlshows the historyscript/differential-sweep.sh --aot --repeat 3unchanged and compliance name-sets unchanged