Skip to content

Compile deep PHP expression chains iteratively#669

Open
mho22 wants to merge 1 commit into
mainfrom
php-iterative-chain-compile-patch
Open

Compile deep PHP expression chains iteratively#669
mho22 wants to merge 1 commit into
mainfrom
php-iterative-chain-compile-patch

Conversation

@mho22

@mho22 mho22 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Why

Zend/zend_compile.c in PHP 8.3.15 walks left-leaning same-opcode chains by recursing through zend_compile_expr on the left subtree:

  • zend_compile_short_circuiting recurses for a || b || c || ... and a && b && c && ...
  • zend_compile_binary_op recurses for a + b + c + ..., a . b . c . ..., and every other binary op

Each chain element consumes one C-stack frame. Native PHP builds tolerate this because the native stack budget is large and zend_check_stack_limit (gated on zend.max_allowed_stack_size) is the trip wire.

WebAssembly hosts (V8, SpiderMonkey, JavaScriptCore) impose a per-frame stack budget one or two orders of magnitude tighter than native — fork-instrumented zend_compile_expr carries more locals and a richer prologue, JSPI stack switching spills more state per suspendable frame, and the musl-on-wasm32-posix calling convention is heavier than Emscripten libc. Long left-leaning chains overflow the host engine before reaching the leftmost leaf. The engine traps with RangeError: Maximum call stack size exceeded (V8) before PHP's own stack-limit check can fire, so the user does not get the upstream Try splitting expression into multiple sub-expressions error either.

Downstream the trap surfaced during the WordPress Playground POSIX-kernel boot path with the kandelo-built PHP wasm. WordPress and other real-world PHP code do contain such chains and should compile on every host PHP runs on. See WordPress/wordpress-playground#3635.

Why not just bump the stack size?

Considered and rejected — the bottleneck isn't the wasm module's stack.

kandelo already links PHP with -Wl,-z,stack-size=4194304 (4 MB — see packages/registry/php/build-php.sh:193). That is the wasm module's declared linear-memory stack. What actually overflows is V8's JS-side per-isolate frame budget during the wasm↔JS transitions JSPI inserts at every suspendable call. The host engine sets that budget; the wasm module cannot widen it.

For comparison, the standard WordPress Playground PHP-WASM (packages/php-wasm/compile/, Emscripten/Asyncify) does not carry an iterative-compile patch and does not tune zend.max_allowed_stack_size either — same recursion, same default PHP budget. It works at WordPress's chain depths because its per-frame footprint is much lighter: no wasm-fork-instrument prologue/epilogue, no JSPI per-frame spill, Emscripten libc instead of musl-wasm32-posix. The same WP_HTML_Processor chain that fits comfortably in PHP-WASM's light frames overflows V8 on kandelo's heavier frames. Bumping STACK_SIZE further does not change that — the recursion is the lever.

Why not exclude zend_compile_short_circuiting from wasm-fork-instrument?

Considered and rejected as unsound. Verified by running tools/bin/wasm-fork-instrument --discover-only against the pre-instrument php-fpm.wasm:

  • 32 of 33 named zend_compile_* / zend_ast_* functions are in the fork-path closure (the closure includes 18,403 of php-fpm's ~63,445 functions; the compile-path is a substantial fraction of it).
  • The compile path legitimately reaches kernel_fork: constant folding during compile triggers __autoload, which runs userland, which can call system()/popen()/shell_exec(). Removing the instrumentation from a function on that path would break the fork-instrument invariant that every fork-path function saves a frame for unwind/rewind — and would trap on rewind with the same null function or function signature mismatch failure mode the recent dispatcher-depth fix (Scale fork-instrument dispatcher beyond 32 call sites #633) was opened against.
  • Trimming the per-frame save shape would require redesigning the unwind ABI rather than tweaking a flag.

The iterative rewrite removes the recursion entirely — the compile-path functions stay fork-instrumented and correct, they just no longer consume one C-frame per chain element.

How

Patch upstream Zend/zend_compile.c so the two recursive walkers flatten before they descend:

  • Walk down node->child[0] while it is the same AST kind and operator as the root, pushing the right children onto a heap array. Stop at the leftmost leaf.
  • Compile the leftmost leaf once with zend_compile_expr.
  • Iterate the array, emitting one rung per element. Each iteration consumes the previous result as the new left_node, so the C-stack stays flat regardless of chain depth.
  • Extract a zend_compile_binary_op_emit helper so zend_compile_binary_op's per-rung specializations (compile-time const folding, ==/=== / concat / shift-by-zero fast paths) are preserved verbatim and reused inside the loop.

Bytecode invariants:

  • JMPZ_EX / JMPNZ_EX semantics for || / && are unchanged: each rung still emits the same jump-to-shared-end shape; the existing target is still the post-chain BOOL (or its elision).
  • The 0 || $a / false && $a const-prefix simplification still fires because the first iteration sees IS_CONST exactly as the recursive form did.
  • zend_compile_binary_op still emits one ZEND_CONCAT per . rung, same opnums.

Idempotency marker: the rewrite introduces the helper zend_compile_binary_op_emit, which is absent from upstream php-src. build-php.sh greps for it before applying the patch so re-running the build against a cached source tree is a no-op.

The patch is being prepared for upstream submission to php/php-src. Until merged and shipped, kandelo carries it locally.

Summary

  • packages/registry/php/patches/iterative-chain-compile.patch (new, 418 lines):
    • Zend/zend_compile.c: iterative rewrite of zend_compile_short_circuiting and zend_compile_binary_op; extracted zend_compile_binary_op_emit helper.
    • Zend/tests/stack_limit/stack_limit_014.phpt: deep || chain (500 elements).
    • Zend/tests/stack_limit/stack_limit_015.phpt: deep && chain (500 elements).
    • Zend/tests/stack_limit/stack_limit_016.phpt: deep + chain (1500 elements).
    • Zend/tests/stack_limit/stack_limit_017.phpt: deep . chain (500 elements).
    • All four phpt tests are gated on zend.max_allowed_stack_size=64K and function_exists('zend_test_zend_call_stack_get'), matching the existing stack_limit_005.phpt / _013.phpt pattern.
  • packages/registry/php/build-php.sh: apply the patch with a grep -q 'zend_compile_binary_op_emit' idempotency guard.
  • packages/registry/php/build.toml: bump revision 3 → 4 to invalidate cached PHP wasm binaries on the next cargo xtask build-deps resolve php.

Tests

PHP build:

  • Two consecutive bash packages/registry/php/build-php.sh invocations from a clean source tree — both succeed; second run is a no-op for the patch step (idempotency guard hits).
  • Final binary sizes: php-fpm.wasm 37,128,296 bytes, php.wasm 36,895,015 bytes, opcache.so 333,283 bytes.
  • Patch dry-run against a pristine PHP 8.3.15 tarball extraction: applies cleanly with patch -p1.

Downstream integration (WordPress Playground POSIX-kernel chromium e2e on the with-patch kandelo binary):

  • npx nx run playground-website:e2e:playwright:posix-kernel -- --project=chromium95 passed, 42 skipped, 0 failed in 17m 18s.
  • The regression-specific test packages/playground/website/playwright/e2e/posix-kernel/iterative-or-probe.spec.ts exercises a 100-deep left-leaning || chain and is part of the 95 passing.

Notes

  • The iterative-flatten technique is applied to two AST kinds today (ZEND_AST_AND/OR and ZEND_AST_BINARY_OP). Other recursive-descent compilers in Zend/zend_compile.c (zend_compile_dim, zend_compile_prop, zend_compile_call, zend_compile_coalesce, zend_compile_conditional) could theoretically overflow on pathological inputs. None has hit recursion in the downstream WordPress boot path; the fix shape is established if a future workload trips one.
  • The new phpt tests use zend.max_allowed_stack_size=64K to force native PHP to bail at the same depth wasm engines would have overflowed at. This keeps the tests portable and deterministic on native CI without depending on host wasm runtime characteristics.
  • Other kandelo test surfaces (cargo, host vitest non-PHP, libc-test, posix-test, ABI snapshot) are unaffected — this PR only changes the PHP wasm binary build inputs.

Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com

PHP 8.3.15's zend_compile_short_circuiting and zend_compile_binary_op
walk left-leaning same-opcode chains (e.g. `a || b || c || ...`,
`a + b + c + ...`) by recursing through zend_compile_expr on the
left subtree, consuming one C-stack frame per chain element.

WebAssembly engines (V8, SpiderMonkey, JavaScriptCore) impose a
per-frame stack budget one or two orders of magnitude tighter than a
native PHP build. Long chains overflow the host engine before
reaching the leftmost leaf — the engine traps before PHP's own
zend_check_stack_limit can fire, with no opportunity for a "Try
splitting expression" error. WordPress and other downstream PHP code
contain such chains; they should compile and run on every host PHP
runs on.

Apply the iterative-chain-compile patch during the PHP build:

- Zend/zend_compile.c: flatten left-leaning same-opcode chains into
  a heap array, compile the leftmost leaf once, then iterate
  emitting one rung at a time. Compile-time const-folding and per-
  rung specializations (equality / identity / concat) preserved via
  the extracted zend_compile_binary_op_emit helper.
- Zend/tests/stack_limit/stack_limit_{014,015,016,017}.phpt: four
  new tests gated on zend.max_allowed_stack_size=64K covering deep
  ||, &&, +, . chains.

The patch is being prepared for upstream submission to php/php-src.
Until merged and shipped, kandelo applies it locally with an
idempotency marker (grep for zend_compile_binary_op_emit). Bump
build.toml revision 3 → 4 to invalidate cached binaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Phase B-1 matrix build status — pr-669-staging

ABI v14. 59 built, 7 failed, 66 total.

Package Arch Status Sha
libcurl wasm32 built e5a13d8d
libcxx wasm32 built f66a6604
libcxx wasm64 built 1a9c8659
libpng wasm32 built 15ed3cfb
libxml2 wasm32 built 4912ddec
libxml2 wasm64 built 0f264e25
ncurses wasm32 built d097bd15
openssl wasm32 built f1692aaa
openssl wasm64 built bc008447
sqlite wasm32 built 8b5c3f21
sqlite wasm64 built bfd888d9
zlib wasm32 built 41c7d171
zlib wasm64 built 3703432b
bash wasm32 built b194796c
bc wasm32 built 3e4cb805
bzip2 wasm32 built 139e057c
coreutils wasm32 built f041cdc4
curl wasm32 built 5cd343a2
dash wasm32 built 1ed5b470
diffutils wasm32 built 61852bb7
dinit wasm32 built dbb5afa9
fbdoom wasm32 built 933689bf
file wasm32 built 98768983
findutils wasm32 built 221935c3
gawk wasm32 built 4dd82709
git wasm32 built 08f28bbf
grep wasm32 built df39087d
gzip wasm32 built a47302be
kandelo-sdk wasm32 built 9006d5b3
kernel wasm32 built bfd4dbf7
lamp wasm32 built 7df6313e
less wasm32 built df1c4ea1
lsof wasm32 built 9c850182
m4 wasm32 built 3a73dca7
make wasm32 built 2946c5c8
mariadb-test wasm32 built 1f39fbb1
mariadb-vfs wasm32 built e97039b9
mariadb-vfs wasm64 built bb78e54e
mariadb wasm32 built f2e08582
mariadb wasm64 built dda7a105
msmtpd wasm32 built 7485b0e0
nano wasm32 built 88d24790
netcat wasm32 built b3500c1e
nethack-browser-bundle wasm32 built 545e50f6
nethack wasm32 built 3f26ca09
nginx wasm32 built 117d42b0
node-vfs wasm32 failed (prev d53be667)
node wasm32 built 3a3647b0
php wasm32 built f4d9543b
posix-utils-lite wasm32 built ec8fbab6
rootfs wasm32 failed (prev fb336bf6)
sed wasm32 built 90fd12c6
shell wasm32 failed
spidermonkey-node wasm32 failed
spidermonkey wasm32 built 8734bd3d
tar wasm32 built 1b406839
tcl wasm32 built 73b1c2af
unzip wasm32 built 036c5a61
userspace wasm32 failed
vim-browser-bundle wasm32 built 0e925f22
vim wasm32 built 5adb58ce
wget wasm32 failed
wordpress wasm32 built 5377856b
xz wasm32 built 3bc5088e
zip wasm32 failed
zstd wasm32 built 741d05bf

Auto-generated; replaced on each push. Raw data in the publish-status workflow artifact.

@mho22

mho22 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

I don't know why I have failing shell, spidermonkey-node, wget and zip builds here while I only modified the packages/registry/php directory. So I guess it is not related to this PR, therefore I am marking this PR as Ready for review.

@mho22 mho22 marked this pull request as ready for review June 10, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant