Skip to content

Cache freed VM stack pages instead of freeing them immediately - #23470

Open
ondrejmirtes wants to merge 3 commits into
php:masterfrom
ondrejmirtes:vm-stack-page-cache
Open

Cache freed VM stack pages instead of freeing them immediately#23470
ondrejmirtes wants to merge 3 commits into
php:masterfrom
ondrejmirtes:vm-stack-page-cache

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Contributor

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

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. Profiling
PHPStan analysing a large codebase in a single process showed 73-90% of
all CPU time in zend_mm_alloc_pages reached from the
ZEND_INIT_METHOD_CALL handlers, i.e. pure VM stack page churn. An
isolated reproducer (recursion to a fixed depth in a loop, heap
pre-fragmented with interleaved small allocations) runs 15-22x slower
than on a fresh heap.

Keep up to 32 freed standard-size pages in a per-executor free list and
serve zend_vm_stack_new_page() from it; flush the list in
zend_vm_stack_destroy(). Oversized pages (frames larger than the page
size) are still freed eagerly. With the cache, the fragmented-heap
reproducer matches the fresh-heap numbers at every depth, and deep
recursion on a fresh heap improves as well (331.7 -> 229.6us per
oscillation at depth 9000), since even a fast allocator round-trip is
slower than popping a cached page.

Benchmark and reproducer:
https://gist.github.com/ondrejmirtes/1c1bc4894e63ddcb6c58d7bfe59cdb7a
Avoid poluting the main cache with different-sized pages, and do not clear the
main cache when a fiber terminates
@arnaud-lb
arnaud-lb force-pushed the vm-stack-page-cache branch from a7ce02e to d15d831 Compare August 26, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants