Commit ba5e9f2517d for php.net
commit ba5e9f2517de2e98652d7d879ce89c30ea766ddb
Author: Ondřej Mirtes <ondrej@mirtes.cz>
Date: Mon Sep 21 14:03:04 2026 +0200
Cache freed VM stack pages instead of freeing them immediately (#23470)
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
Co-authored-by: Arnaud Le Blanc <arnaud.lb@gmail.com>
diff --git a/UPGRADING b/UPGRADING
index 6299ef4611a..7f1fc588bd0 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -1101,6 +1101,8 @@ PHP 8.6 UPGRADE NOTES
. The performance of ZTS builds has been improved.
. Added stateless closure cache.
RFC: https://wiki.php.net/rfc/closure-optimizations#stateless_closure_caching
+ . Deeply recursive code that causes the VM to allocate new stack pages should
+ now be faster.
- DOM:
. Made splitText() faster and consume less memory.
diff --git a/Zend/zend_execute.c b/Zend/zend_execute.c
index f690cfab6f8..799475d7df9 100644
--- a/Zend/zend_execute.c
+++ b/Zend/zend_execute.c
@@ -191,6 +191,10 @@ ZEND_API const zend_internal_function zend_pass_function = {
ZEND_API void zend_vm_stack_init(void)
{
EG(vm_stack_page_size) = ZEND_VM_STACK_PAGE_SIZE;
+ EG(vm_stack_page_cache) = NULL;
+ EG(vm_stack_page_cache_count) = 0;
+ EG(fiber_vm_stack_page_cache) = NULL;
+ EG(fiber_vm_stack_page_cache_count) = 0;
EG(vm_stack) = zend_vm_stack_new_page(ZEND_VM_STACK_PAGE_SIZE, NULL);
EG(vm_stack_top) = EG(vm_stack)->top;
EG(vm_stack_end) = EG(vm_stack)->end;
@@ -201,6 +205,10 @@ ZEND_API void zend_vm_stack_init_ex(size_t page_size)
/* page_size must be a power of 2 */
ZEND_ASSERT(page_size > 0 && (page_size & (page_size - 1)) == 0);
EG(vm_stack_page_size) = page_size;
+ EG(vm_stack_page_cache) = NULL;
+ EG(vm_stack_page_cache_count) = 0;
+ EG(fiber_vm_stack_page_cache) = NULL;
+ EG(fiber_vm_stack_page_cache_count) = 0;
EG(vm_stack) = zend_vm_stack_new_page(page_size, NULL);
EG(vm_stack_top) = EG(vm_stack)->top;
EG(vm_stack_end) = EG(vm_stack)->end;
@@ -217,6 +225,23 @@ ZEND_API void zend_vm_stack_destroy(void)
}
}
+ZEND_API void zend_vm_stack_destroy_caches(void)
+{
+ while (EG(vm_stack_page_cache) != NULL) {
+ zend_vm_stack cached = EG(vm_stack_page_cache);
+ EG(vm_stack_page_cache) = cached->prev;
+ efree(cached);
+ }
+ EG(vm_stack_page_cache_count) = 0;
+
+ while (EG(fiber_vm_stack_page_cache) != NULL) {
+ zend_vm_stack cached = EG(fiber_vm_stack_page_cache);
+ EG(fiber_vm_stack_page_cache) = cached->prev;
+ efree(cached);
+ }
+ EG(fiber_vm_stack_page_cache_count) = 0;
+}
+
ZEND_API void* zend_vm_stack_extend(size_t size)
{
zend_vm_stack stack;
diff --git a/Zend/zend_execute.h b/Zend/zend_execute.h
index 017c8d208a1..2250a873af2 100644
--- a/Zend/zend_execute.h
+++ b/Zend/zend_execute.h
@@ -322,10 +322,41 @@ ZEND_STATIC_ASSERT(ZEND_MM_ALIGNED_SIZE(sizeof(zval)) == sizeof(zval),
ZEND_API void zend_vm_stack_init(void);
ZEND_API void zend_vm_stack_init_ex(size_t page_size);
ZEND_API void zend_vm_stack_destroy(void);
+ZEND_API void zend_vm_stack_destroy_caches(void);
ZEND_API void* zend_vm_stack_extend(size_t size);
+#define ZEND_FIBER_VM_STACK_SIZE (1024 * sizeof(zval))
+
+static zend_always_inline zend_vm_stack zend_vm_stack_cached_page(size_t size) {
+ zend_vm_stack page;
+
+ if (size == ZEND_FIBER_VM_STACK_SIZE) {
+ page = EG(fiber_vm_stack_page_cache);
+ if (page) {
+ ZEND_ASSERT((size_t)((char*)page->end - (char*)page) == size);
+ EG(fiber_vm_stack_page_cache) = page->prev;
+ EG(fiber_vm_stack_page_cache_count)--;
+ return page;
+ }
+ } else {
+ page = EG(vm_stack_page_cache);
+ ZEND_ASSERT(!page || ((size_t)((char*)page->end - (char*)page) == size) || size != EG(vm_stack_page_size));
+ if (page && EXPECTED((size_t)((char*)page->end - (char*)page) == size)) {
+ EG(vm_stack_page_cache) = page->prev;
+ EG(vm_stack_page_cache_count)--;
+ return page;
+ }
+ }
+
+ return NULL;
+}
+
static zend_always_inline zend_vm_stack zend_vm_stack_new_page(size_t size, zend_vm_stack prev) {
- zend_vm_stack page = (zend_vm_stack)emalloc(size);
+ zend_vm_stack page = zend_vm_stack_cached_page(size);
+
+ if (!page) {
+ page = (zend_vm_stack)emalloc(size);
+ }
page->top = ZEND_VM_STACK_ELEMENTS(page);
page->end = (zval*)((char*)page + size);
@@ -421,7 +452,24 @@ static zend_always_inline void zend_vm_stack_free_call_frame_ex(uint32_t call_in
EG(vm_stack_top) = prev->top;
EG(vm_stack_end) = prev->end;
EG(vm_stack) = prev;
- efree(p);
+ if ((size_t)((char*)p->end - (char*)p) == ZEND_FIBER_VM_STACK_SIZE) {
+ if (EG(fiber_vm_stack_page_cache_count) < 32) {
+ p->prev = EG(fiber_vm_stack_page_cache);
+ EG(fiber_vm_stack_page_cache) = p;
+ EG(fiber_vm_stack_page_cache_count)++;
+ } else {
+ efree(p);
+ }
+ } else {
+ if (EG(vm_stack_page_cache_count) < 32
+ && (size_t)((char*)p->end - (char*)p) == EG(vm_stack_page_size)) {
+ p->prev = EG(vm_stack_page_cache);
+ EG(vm_stack_page_cache) = p;
+ EG(vm_stack_page_cache_count)++;
+ } else {
+ efree(p);
+ }
+ }
} else {
EG(vm_stack_top) = (zval*)call;
}
diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
index c67a31fd8de..ada9a3ac75b 100644
--- a/Zend/zend_execute_API.c
+++ b/Zend/zend_execute_API.c
@@ -482,6 +482,7 @@ void shutdown_executor(void) /* {{{ */
zend_hash_discard(EG(class_table), EG(persistent_classes_count));
} else {
zend_vm_stack_destroy();
+ zend_vm_stack_destroy_caches();
if (EG(full_tables_cleanup)) {
zend_hash_reverse_apply(EG(function_table), clean_non_persistent_function_full);
diff --git a/Zend/zend_fibers.h b/Zend/zend_fibers.h
index c72ffdc8f18..ec495c9ab9e 100644
--- a/Zend/zend_fibers.h
+++ b/Zend/zend_fibers.h
@@ -25,7 +25,6 @@
#define ZEND_FIBER_GUARD_PAGES 1
#define ZEND_FIBER_DEFAULT_C_STACK_SIZE (4096 * (((sizeof(void *)) < 8) ? 256 : 512))
-#define ZEND_FIBER_VM_STACK_SIZE (1024 * sizeof(zval))
BEGIN_EXTERN_C()
diff --git a/Zend/zend_globals.h b/Zend/zend_globals.h
index 4d5e300e285..b7835ed6322 100644
--- a/Zend/zend_globals.h
+++ b/Zend/zend_globals.h
@@ -328,6 +328,12 @@ struct _zend_executor_globals {
HashTable partial_function_application_cache;
zend_stack lambda_cache;
+ zend_vm_stack vm_stack_page_cache;
+ uint32_t vm_stack_page_cache_count;
+ /* Fibers use a different page size, so they need a separate cache */
+ zend_vm_stack fiber_vm_stack_page_cache;
+ uint32_t fiber_vm_stack_page_cache_count;
+
void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};