Compile deep PHP expression chains iteratively#669
Open
mho22 wants to merge 1 commit into
Open
Conversation
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>
Phase B-1 matrix build status —
|
| 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.
Collaborator
Author
|
I don't know why I have failing |
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.
Why
Zend/zend_compile.cin PHP 8.3.15 walks left-leaning same-opcode chains by recursing throughzend_compile_expron the left subtree:zend_compile_short_circuitingrecurses fora || b || c || ...anda && b && c && ...zend_compile_binary_oprecurses fora + b + c + ...,a . b . c . ..., and every other binary opEach 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 onzend.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_exprcarries 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 withRangeError: Maximum call stack size exceeded(V8) before PHP's own stack-limit check can fire, so the user does not get the upstreamTry splitting expression into multiple sub-expressionserror 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 — seepackages/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 tunezend.max_allowed_stack_sizeeither — same recursion, same default PHP budget. It works at WordPress's chain depths because its per-frame footprint is much lighter: nowasm-fork-instrumentprologue/epilogue, no JSPI per-frame spill, Emscripten libc instead of musl-wasm32-posix. The sameWP_HTML_Processorchain that fits comfortably in PHP-WASM's light frames overflows V8 on kandelo's heavier frames. BumpingSTACK_SIZEfurther does not change that — the recursion is the lever.Why not exclude
zend_compile_short_circuitingfromwasm-fork-instrument?Considered and rejected as unsound. Verified by running
tools/bin/wasm-fork-instrument --discover-onlyagainst the pre-instrumentphp-fpm.wasm: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).kernel_fork: constant folding during compile triggers__autoload, which runs userland, which can callsystem()/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 samenull function or function signature mismatchfailure mode the recent dispatcher-depth fix (Scale fork-instrument dispatcher beyond 32 call sites #633) was opened against.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.cso the two recursive walkers flatten before they descend: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.zend_compile_expr.left_node, so the C-stack stays flat regardless of chain depth.zend_compile_binary_op_emithelper sozend_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_EXsemantics for||/&&are unchanged: each rung still emits the same jump-to-shared-end shape; the existing target is still the post-chainBOOL(or its elision).0 || $a/false && $aconst-prefix simplification still fires because the first iteration seesIS_CONSTexactly as the recursive form did.zend_compile_binary_opstill emits oneZEND_CONCATper.rung, same opnums.Idempotency marker: the rewrite introduces the helper
zend_compile_binary_op_emit, which is absent from upstream php-src.build-php.shgreps 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 ofzend_compile_short_circuitingandzend_compile_binary_op; extractedzend_compile_binary_op_emithelper.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).zend.max_allowed_stack_size=64Kandfunction_exists('zend_test_zend_call_stack_get'), matching the existingstack_limit_005.phpt/_013.phptpattern.packages/registry/php/build-php.sh: apply the patch with agrep -q 'zend_compile_binary_op_emit'idempotency guard.packages/registry/php/build.toml: bumprevision3 → 4 to invalidate cached PHP wasm binaries on the nextcargo xtask build-deps resolve php.Tests
PHP build:
bash packages/registry/php/build-php.shinvocations from a clean source tree — both succeed; second run is a no-op for the patch step (idempotency guard hits).php-fpm.wasm37,128,296 bytes,php.wasm36,895,015 bytes,opcache.so333,283 bytes.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=chromium— 95 passed, 42 skipped, 0 failed in 17m 18s.packages/playground/website/playwright/e2e/posix-kernel/iterative-or-probe.spec.tsexercises a 100-deep left-leaning||chain and is part of the 95 passing.Notes
ZEND_AST_AND/ORandZEND_AST_BINARY_OP). Other recursive-descent compilers inZend/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.zend.max_allowed_stack_size=64Kto 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.Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com