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

[email protected] (ondrejmirtes)
Newsgroups php.git-pulls
Message-ID <BZXMILftQDvxFTAvt3tSH3TA7Idluz8F9x8RfoXhc4M@main.internal.php.net>
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.
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.