Cache freed VM stack pages instead of freeing them immediately - #23470
Open
ondrejmirtes wants to merge 3 commits into
Open
Cache freed VM stack pages instead of freeing them immediately#23470ondrejmirtes wants to merge 3 commits into
ondrejmirtes wants to merge 3 commits into
Conversation
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
EG() is not zeroed in ZTS builds
Avoid poluting the main cache with different-sized pages, and do not clear the main cache when a fiber terminates
arnaud-lb
force-pushed
the
vm-stack-page-cache
branch
from
August 26, 2026 12:46
5978851 to
a7ce02e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 immediateefree()inzend_vm_stack_free_call_frame_ex()on the way back. Every such allocation goes throughzend_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 anmmap/munmapround-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 inzend_mm_alloc_pagesreached from theZEND_INIT_METHOD_CALLhandlers — 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 servezend_vm_stack_new_page()from it; flush the list inzend_vm_stack_destroy(). Details relevant to review: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.zend_vm_stack_destroy(), so nothing outlives the request. In practice an oscillating workload holds one or two pages.zend_vm_stack_new_page(); the reclaim path replaces anefree()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-allbuild of master (c621cbe27ff), 20,000 iterations per cell, µs per oscillation: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.