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];
 };