[php-src] PR #23470: Cache freed VM stack pages instead of freeing them immediately

From: Date: Wed, 26 Aug 2026 08:33:30 +0000
Subject: [php-src] PR #23470: Cache freed VM stack pages instead of freeing them immediately
Groups: php.git-pulls 
Request: Send a blank email to git-pulls+get-38151@lists.php.net to get a copy of this message
Pull Request: https://github.com/php/php-src/pull/23470 Author: ondrejmirtes > **Disclaimer:** I found out this issue when measuring performance of a giant subsystem rewrite > in PHPStan, which performed better on many projects but worse on a particular one. This issue and > pull request was discovered and debugged with the help of an LLM, afterwards discussed privately > with Arnaud Le Blanc who told me that it's an interesting finding and worth reviewing. A call stack whose depth oscillates across a VM stack page boundary allocates and frees a 256KB page on every oscillation: zend_vm_stack_extend() on the way down, and the immediate efree() in zend_vm_stack_free_call_frame_ex() on the way back. Every such allocation goes through zend_mm_alloc_large(), whose search for contiguous free pages degrades on a large, fragmented heap — in the worst case scanning every chunk's free-page bitmap, failing, and paying an mmap/munmap round-trip per oscillation. Long-running processes with multi-GB heaps hit this hard. I found it profiling PHPStan analysing a large codebase in a single process (gc_disable(), multi-GB heap): **73–90% of all CPU time** was in zend_mm_alloc_pages reached from the ZEND_INIT_METHOD_CALL handlers — pure VM stack page churn, no useful work. ### The change Keep up to 32 freed standard-size VM stack pages in a per-executor free list (EG(vm_stack_page_cache)) and serve zend_vm_stack_new_page() from it; flush the list in zend_vm_stack_destroy(). Details relevant to review: - Only pages of exactly EG(vm_stack_page_size) are cached (checked on both push and pop); oversized pages — frames larger than the page size — are still freed eagerly. - The retention ceiling is 32 × 256KB = 8MB per executor, and the cache is flushed at zend_vm_stack_destroy(), so nothing outlives the request. In practice an oscillating workload holds one or two pages. - The cache lives in executor globals, so ZTS builds get one per thread; no shared state. - The fast path adds one predictable branch to zend_vm_stack_new_page(); the reclaim path replaces an efree() with a list push under the same size check. ### Benchmark Isolated reproducer ([gist with benchmark + reproducer](https://gist.github.com/ondrejmirtes/1c1bc4894e63ddcb6c58d7bfe59cdb7a)): recursion to a fixed depth in a loop, with the heap optionally pre-fragmented by ~600k interleaved ~5.5KB allocations keeping every other one, so the free space is 2-page holes that can never satisfy a 64-page VM stack request. Each iteration is one full depth oscillation. Apple M1 Pro, macOS, NTS, minimal --disable-all build of master (c621cbe27ff), 20,000 iterations per cell, µs per oscillation: | depth | master, fresh heap | master, fragmented | patched, fresh | patched, fragmented | |------:|------:|------:|------:|------:| | 1,500 | 40.0 | 40.1 | 40.1 | 38.5 | | 3,000 | 79.0 | 79.7 | 77.5 | 77.0 | | 4,500 | 119.5 | **1,846.1 (15.4×)** | 114.7 | 116.4 | | 6,000 | 158.2 | **3,543.9 (22.4×)** | 152.7 | 154.0 | | 9,000 | 331.7 | **7,185.8 (21.7×)** | 229.6 | 230.4 | The cliff between depth 3,000 and 4,500 is where the recursion outgrows the initial VM stack page and every iteration starts paying the alloc/free cycle. With the patch the fragmented column matches the fresh column at every depth — and the deep fresh-heap case improves too (depth 9,000: 331.7 → 229.6µs), since even a fast allocator round-trip is slower than popping a cached page. Real-workload effect: PHPStan (before it shipped its own userland mitigation), analysing the same 40 files of a large codebase single-process with the heap pre-fragmented as above: **537s → 51s wall** with the patch — identical to its fresh-heap time.

« previous php.git-pulls (#38151) next »