|
| 1 | +# INVESTIGATE: opcache optimizer differences with the profiler (PHP < 8.4) |
| 2 | + |
| 3 | +Status: **xfailed for PHP ≤ 8.3** (see `php-language-xfail-pre84.list`). This |
| 4 | +file records what we know so it can be debugged properly later. |
| 5 | + |
| 6 | +## Affected tests (PHP language tests job, profiler loaded, PHP ≤ 8.3) |
| 7 | + |
| 8 | +- `ext/opcache/tests/opt/prop_types.phpt` |
| 9 | +- `ext/opcache/tests/opt/gh11170.phpt` |
| 10 | +- `ext/opcache/tests/opt/nullsafe_002.phpt` |
| 11 | +- `ext/opcache/tests/bug66251.phpt` |
| 12 | + |
| 13 | +All four **pass** on PHP 8.4 ZTS with the profiler, and **pass** on every |
| 14 | +version without the profiler. They only fail with the profiler on PHP ≤ 8.3. |
| 15 | + |
| 16 | +## Why it is PHP ≤ 8.3 only |
| 17 | + |
| 18 | +The wall-time profiler overrides the global `zend_execute_internal` |
| 19 | +(`profiling/src/wall_time.rs`, `mod execute_internal`) so it can handle a |
| 20 | +pending VM interrupt while an internal function (e.g. `sleep`, `curl_exec`) |
| 21 | +is still on top of the call stack — otherwise that time is misattributed to |
| 22 | +whatever runs next. |
| 23 | + |
| 24 | +That `mod` is gated: |
| 25 | + |
| 26 | +```rust |
| 27 | +#[cfg(not(php_frameless))] |
| 28 | +mod execute_internal { ... pub unsafe fn minit() { zend_execute_internal = Some(execute_internal); } } |
| 29 | +``` |
| 30 | + |
| 31 | +`php_frameless` is set for PHP 8.4+ (frameless internal calls; see |
| 32 | +php-src PR 14627, "Levi changed this in 8.4"), so the hook is **only installed |
| 33 | +on PHP < 8.4**. On 8.4+ the engine processes the interrupt itself, the hook is |
| 34 | +not needed, and `zend_execute_internal` stays NULL. |
| 35 | + |
| 36 | +Note: `zend_execute_ex` is **not** hooked by the profiler (verified — only the |
| 37 | +`Generator::throw()` *method handler* is wrapped in `exception.rs`; the |
| 38 | +`prev_execute_data` there is a struct field, not the execute hook). So user |
| 39 | +function dispatch (`DO_UCALL`) is unaffected; only internal calls are. |
| 40 | + |
| 41 | +## Group 1 — `DO_ICALL` → `DO_FCALL` (cosmetic; prop_types, gh11170, nullsafe_002) |
| 42 | + |
| 43 | +These tests dump optimized opcodes (`opcache.opt_debug_level`) and assert that |
| 44 | +calls to internal functions (`rand`, `var_dump`, …) compile to `DO_ICALL`. |
| 45 | + |
| 46 | +The compiler only emits `DO_ICALL` when `zend_execute_internal` is NULL |
| 47 | +(`Zend/zend_compile.c`, `zend_get_call_op`): |
| 48 | + |
| 49 | +```c |
| 50 | +if (fbc->type == ZEND_INTERNAL_FUNCTION && !(CG(compiler_options) & ZEND_COMPILE_IGNORE_INTERNAL_FUNCTIONS)) { |
| 51 | + if (init_op->opcode == ZEND_INIT_FCALL && !zend_execute_internal) { // <-- gate |
| 52 | + if (!(fbc->common.fn_flags & ZEND_ACC_DEPRECATED)) { |
| 53 | + return ZEND_DO_ICALL; |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | +... |
| 58 | +return ZEND_DO_FCALL; |
| 59 | +``` |
| 60 | + |
| 61 | +Because the profiler sets `zend_execute_internal`, the engine must route |
| 62 | +internal calls through the generic `DO_FCALL` (which honors the hook); |
| 63 | +`DO_ICALL` would call the handler directly and bypass it. So the optimized |
| 64 | +opcodes legitimately differ. This is the same behavior as ddtrace and |
| 65 | +DTrace-enabled PHP builds. It fires even with `DD_PROFILING_ENABLED=0` because |
| 66 | +the hook is installed at MINIT unconditionally. |
| 67 | + |
| 68 | +**Verdict:** harmless opcode-dump mismatch. Nothing to fix; just xfail on ≤8.3. |
| 69 | + |
| 70 | +## Group 2 — `bug66251.phpt`: a REAL behavioral divergence (needs debugging) |
| 71 | + |
| 72 | +This is **not** cosmetic. Test body: |
| 73 | + |
| 74 | +```php |
| 75 | +<?php |
| 76 | +printf("A=%s\n", getA()); // called before A is defined |
| 77 | +const A = "hello"; |
| 78 | +function getA() { return A; } |
| 79 | +``` |
| 80 | + |
| 81 | +Correct behavior (the whole point of bug #66251): opcache must NOT fold the |
| 82 | +same-file runtime constant `A` into `getA()`. At the time `getA()` runs, `A` |
| 83 | +is not yet defined → `Fatal error: Undefined constant "A"`. |
| 84 | + |
| 85 | +With the profiler on PHP ≤ 8.3, opcache **folds** `A` → the program prints |
| 86 | +`A=hello` instead of throwing. That is a semantic change, not just an opcode |
| 87 | +dump. |
| 88 | + |
| 89 | +### It is not a test-harness artifact |
| 90 | + |
| 91 | +run-tests.php exposes it because it sets `opcache.file_update_protection=0`, |
| 92 | +so the freshly generated test file is cached immediately. But that is not a |
| 93 | +test-only condition — **any file older than 2 s (the default) gets cached the |
| 94 | +same way**. Reproduced with default opcache settings on an aged file: |
| 95 | + |
| 96 | +```sh |
| 97 | +# profiler loaded via conf.d; OC = path to opcache.so |
| 98 | +printf '<?php\nprintf("A=%%s\\n", getA());\nconst A="hello";\nfunction getA() {return A;}\n' > /tmp/b.php |
| 99 | +touch -d "1 hour ago" /tmp/b.php |
| 100 | + |
| 101 | +php -d zend_extension=$OC -d opcache.enable=1 -d opcache.enable_cli=1 \ |
| 102 | + -d opcache.optimization_level=-1 -f /tmp/b.php |
| 103 | +# profiler + PHP<=8.3 -> "A=hello" (WRONG: constant folded) |
| 104 | +# no profiler -> Fatal error: Undefined constant "A" (correct) |
| 105 | +# PHP 8.4+ (any) -> Fatal error: Undefined constant "A" (correct) |
| 106 | +``` |
| 107 | + |
| 108 | +Minimal trigger matrix (PHP 8.3 ZTS): |
| 109 | + |
| 110 | +| profiler | opcache.file_update_protection | result | |
| 111 | +|---|---|---| |
| 112 | +| off | 0 | fatal (correct) | |
| 113 | +| on | 2 (default), fresh file | fatal (correct; file too new to cache) | |
| 114 | +| on | 0, or default + aged file | **A=hello (wrong)** | |
| 115 | + |
| 116 | +So: profiler loaded **and** opcache actually caching the file → constant |
| 117 | +folded. |
| 118 | + |
| 119 | +### Open question / where to dig next |
| 120 | + |
| 121 | +Why does loading the profiler make opcache fold a constant it otherwise |
| 122 | +defers? The profiler's only relevant engine change on ≤8.3 is the |
| 123 | +`zend_execute_internal` override (which turns internal calls into `DO_FCALL`) |
| 124 | +plus being registered as a `zend_extension`. Hypothesis: the |
| 125 | +`DO_UCALL`/`DO_ICALL` → `DO_FCALL` change alters opcache's call-graph / SCCP |
| 126 | +analysis enough that the deferred-constant guard from bug #66251 no longer |
| 127 | +triggers, so SCCP substitutes `A`. Needs confirmation by: |
| 128 | + |
| 129 | +1. Dumping `getA()`'s optimized opcodes with/without the profiler under |
| 130 | + `opcache.file_update_protection=0` (the difference will be a folded |
| 131 | + `RETURN string("hello")` vs a `FETCH_CONSTANT A` + `RETURN`). |
| 132 | +2. Bisecting which opcache optimizer pass (SCCP / DFA / pass1 constant |
| 133 | + propagation) does the fold, and whether it keys off the call opcode. |
| 134 | +3. Checking whether ddtrace (also overrides `zend_execute_internal` on ≤8.3) |
| 135 | + reproduces — if so this is a general "VM-hook + opcache" issue, not |
| 136 | + profiler-specific. |
| 137 | + |
| 138 | +This likely affects real programs that reference a constant before its |
| 139 | +same-file definition (uncommon, but the divergence is real). |
| 140 | + |
| 141 | +## Reproducing the whole set |
| 142 | + |
| 143 | +```sh |
| 144 | +# 8.3 ZTS image, profiler built into /tmp/cargo, loaded via conf.d profiling.ini |
| 145 | +cd /usr/local/src/php |
| 146 | +php run-tests.php -q -p /usr/local/bin/php \ |
| 147 | + ext/opcache/tests/opt/prop_types.phpt \ |
| 148 | + ext/opcache/tests/opt/gh11170.phpt \ |
| 149 | + ext/opcache/tests/opt/nullsafe_002.phpt \ |
| 150 | + ext/opcache/tests/bug66251.phpt |
| 151 | +# all 4 FAIL with profiler on <=8.3; all pass without profiler or on 8.4+ |
| 152 | +``` |
0 commit comments