[php-src] PR #23470: Cache freed VM stack pages instead of freeing them immediately
| From: | ondrejmirtes | 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.