Skip to content

Fix GH-15869: Stack overflow in zend_array_destroy with deeply nested arrays#21494

Open
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/gh-15869-array-destroy-stack-overflow
Open

Fix GH-15869: Stack overflow in zend_array_destroy with deeply nested arrays#21494
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/gh-15869-array-destroy-stack-overflow

Conversation

@iliaal
Copy link
Contributor

@iliaal iliaal commented Mar 23, 2026

Summary

zend_array_destroy() recurses via i_zval_ptr_dtor for each element, overflowing the C stack at ~40-50k nesting levels.

The fix combines two mechanisms:

  1. Tail-call optimization for the first child array whose refcount reaches zero -- loops back instead of recursing. Zero overhead for linear chains ($a = [$a]).

  2. Destroy stack for additional array children -- when multiple elements are arrays with refcount reaching zero, extras are pushed onto a heap-backed stack (with a small on-stack buffer to avoid allocation in the common case). After the tail-call chain completes, items are popped from the stack and processed iteratively.

This eliminates C stack growth for array destruction regardless of nesting shape: linear chains, COW-shared branches ($a = [$a, $a]), and independent deep branches all run in constant stack depth.

Non-array refcounted values (objects, strings, etc.) are still destroyed immediately via rc_dtor_func, preserving existing destructor behavior.

Fixes #15869

Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would fix the stack overflow only for a single particular case.
The problem might be simple re-reproduced with $a=[$a,$a] or with objects.
Also the fix may change the order of object destructors (probably this is not critical for master branch), and may interfere with GC.

I'm not sure if we should accept this incomplete fix.

The complete fix would require maintaining a separate destruction queue, but this may introduce different troubles.

@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

Updated the branch to address the multi-branch case. I added a destroy stack alongside the tail-call.

On each point:

$a = [$a, $a]: COW actually makes this work with the original single-child optimization too. Both elements share the same zend_array with refcount 2. First GC_DELREF brings it to 1 (gc_check_possible_root), second to 0 (deferred via child). The whole chain linearizes, zero recursion.

The real gap was distinct deep branches, and you were right about that. Two independent 200k-deep chains in one array ($c = [$a, $b]) would still recurse on the second. I've changed it so additional array children get pushed onto a destroy stack instead of going through rc_dtor_func. Linear chains still take the zero-allocation tail-call path. The stack starts with a small on-stack buffer (8 entries) and falls back to emalloc when needed, so the common case pays nothing.

Objects: agreed, this doesn't cover deeply nested object chains. __destruct runs arbitrary PHP code, so deferring it would change observable behavior in ways that array destruction doesn't. I've kept the scope to arrays intentionally.

Destructor ordering: objects still get destroyed immediately via rc_dtor_func during forward iteration. They're never deferred. Only the nested array's own destruction is delayed. Per-array element iteration order stays the same.

GC: GC_REMOVE_FROM_BUFFER(ht) runs at the top of each iteration, including stack-popped arrays, so roots get cleaned up before FREE_HASHTABLE. Deferred arrays go through the same path.

Added gh15869_multi_branch.phpt covering the two-independent-chains case.

@iliaal iliaal force-pushed the fix/gh-15869-array-destroy-stack-overflow branch from 8d66718 to a711e95 Compare March 23, 2026 13:19
@iluuu1994
Copy link
Member

This also adds significant overhead:

From https://github.com/php/php-src/actions/runs/23439349506?pr=21494#summary-68185833867:

Benchmark Base (d34c840) Head (b43e19a) Diff
Zend/bench.php 2235M 2235M 0.00%
Zend/bench.php JIT 604M 604M 0.00%
Symfony Demo 2.2.3 39M 39M 0.22%
Symfony Demo 2.2.3 JIT 34M 34M 0.25%
Wordpress 6.2 123M 123M 0.52%
Wordpress 6.2 JIT 94M 94M 0.69%

@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

This also adds significant overhead:

From https://github.com/php/php-src/actions/runs/23439349506?pr=21494#summary-68185833867:

I Dropped the ds[32] destroy stack. The 256-byte stack allocation on every zend_array_destroy call was the main cost, since WordPress destroys thousands of small arrays per request.

The buffer wasn't needed. When a second child array hits refcount zero in the same parent, it falls through to rc_dtor_func, which calls zend_array_destroy with its own child pointer and tail-calls down its chain independently. Recursion depth is bounded by breadth (sibling count at one level), not nesting depth, so there's no stack overflow risk.

What's left is one child pointer (8 bytes), a type check in the cold refcount-zero path, and an UNEXPECTED branch after FREE_HASHTABLE. This should be quite a bit more performant, while fixing underlying issues

@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

@iluuu1994 With latest changes the results are about 2x better
https://github.com/php/php-src/actions/runs/23451369656/attempts/1#summary-68229157964

But I'll be honest I expected it to be a more... seems like too much overhead still

@iliaal iliaal force-pushed the fix/gh-15869-array-destroy-stack-overflow branch from 30c3cfa to fb8caa0 Compare March 23, 2026 19:05
@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

Reworked the approach. zend_array_destroy now uses the original i_zval_ptr_dtor in all four loops, no macro substitution. The hot path is unchanged.

I added a single zend_call_stack_overflowed(EG(stack_limit)) guard at entry. When the stack is near its limit, it branches to a cold zend_array_destroy_iterative() with tail-call elimination for linear chains and a heap-allocated work list for siblings. WordPress/Symfony never touch this path.

Overhead should be one predicted-not-taken comparison per zend_array_destroy call.

@iluuu1994
Copy link
Member

@iliaal Did you possibly forget to push? I still the the previous implementation.

@iliaal iliaal force-pushed the fix/gh-15869-array-destroy-stack-overflow branch 2 times, most recently from 65fe737 to a3469b4 Compare March 23, 2026 21:13
@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

@iliaal Did you possibly forget to push? I still the the previous implementation.

Actually updating the PR helps, Eh? 🤦That being said I tried the alternative and benchmark actually regressed, going to need to think about this a little more. For now reverting to the patch with best results, but still probably too much of a speed regression to consider

@iliaal iliaal force-pushed the fix/gh-15869-array-destroy-stack-overflow branch from a3469b4 to 5de093e Compare March 23, 2026 21:54
…ted arrays

When zend_array_destroy recurses through rc_dtor_func for nested arrays,
deeply nested structures (200K+ levels) overflow the C stack.

Guard zend_array_destroy with zend_call_stack_overflowed(). When the
stack is near its limit, switch to zend_array_destroy_iterative() which
uses tail-call elimination for linear chains and a heap-allocated work
list for sibling arrays. The hot path is completely unchanged -- only
a single stack-limit comparison is added per call.
@iliaal iliaal force-pushed the fix/gh-15869-array-destroy-stack-overflow branch from 5de093e to 6399068 Compare March 23, 2026 22:19
@iliaal
Copy link
Contributor Author

iliaal commented Mar 23, 2026

@iliaal Did you possibly forget to push? I still the the previous implementation.

Took another stab at a fix, still not quite ideal, but Symfony impact is now well under 0.1%, sadly Wordpress while faster (3x over original) is still probably in sub-optimal state. Going to leave it here for now...

Benchmark Base (8f9a8c0) Head (307ebc0) Diff
Zend/bench.php 2235M 2235M 0.00%
Zend/bench.php JIT 604M 604M 0.00%
Symfony Demo 2.2.3 39M 39M 0.06%
Symfony Demo 2.2.3 JIT 34M 34M 0.07%
Wordpress 6.2 123M 123M 0.14%
Wordpress 6.2 JIT 94M 94M 0.19%

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.

Segmentation fault (stack overflow) in Zend

3 participants