Repository navigation
Zend: Compile a list assignment from an array literal without the array - #24180
Conversation
eb63ed6 to
5bfa8e3
Compare
| 0001 CV1($b) = RECV 2 | ||
| 0002 T2 = QM_ASSIGN CV1($b) | ||
| 0003 T3 = QM_ASSIGN CV0($a) | ||
| 0004 T4 = COPY_TMP T2 |
There was a problem hiding this comment.
It would be great if these COPY_TMPs could be avoided (and thus the FREE as well); that would make the code of this swap function optimal
There was a problem hiding this comment.
The copies keep destructor timing as it is: the array held a reference to every value until the statement was done, so a value its target doesn't keep (a setter that drops it, [$a, $a, …], targets that are references to each other) was only released at the end. Without the copies it dies at that assignment, and a destructor is observable — if it throws, the rest of the statement doesn't run: [$a, $a, $b] = [new Boom, new stdClass, 42] leaves $b unassigned where master assigns it. I had the four-opcode form first and the lifetime test caught exactly this.
For values that can't be refcounted the copies are pure overhead, and that's the optimizer's job: with optimization_level=-1 the typed swap is already T2 = QM_ASSIGN $b; $a = COPY_TMP T2 — the only missing rule is that COPY_TMP of a scalar is QM_ASSIGN, after which the existing contraction gives the shape you have in mind. I'd send that as a follow-up if that works for you.
There was a problem hiding this comment.
Generally I think that optimizations shouldn't change observable behaviour.
However, we already have that happening with e.g. frameless function calls, which is also comparable to intra-expression ordering.
In this case I agree that intra-expression ordering isn't something we should care deeply about.
There was a problem hiding this comment.
Generally I think that optimizations shouldn't change observable behaviour. However, we already have that happening with e.g. frameless function calls, which is also comparable to intra-expression ordering. In this case I agree that intra-expression ordering isn't something we should care deeply about.
This was my reasoning too 👍
Thank you!
There was a problem hiding this comment.
Thanks, done in 86a9f1e (rebased onto current master): the copies are gone and the swap compiles to the four opcodes from the issue, 922 → 467 instructions per call, below a swap through a temporary variable (493), so the optimizer follow-up I mentioned isn't needed. UPGRADING now says that a value its target does not keep is released at that assignment, and the lifetime tests expect the new order; it is the same without opcache, with the optimizer and with both JITs. The PR description has the new numbers.
[$a, $b] = [$b, $a]; built an array of the values, fetched every element out of it again and freed it. When the result of the assignment is not used, the right side is an array literal and the left side a flat list with a target for every value, assign the values to the targets directly instead. The values are still evaluated in order before the first assignment and a variable among them is copied first. Each target then takes its value, so a value its target does not keep (a setter that drops it, the same variable twice, a typed property that coerces it) is released at that assignment rather than after the last one, and a destructor that throws there stops the rest of the statement. Assignments in the expression lists of a for loop, whose results are not used either, are compiled the same way. Nested lists, keys on either side, references, spread, a different number of values and targets and a used result keep the usual compilation. A swap goes from 922 to 467 instructions per call of a function doing it, below a swap through a temporary variable (493).
5bfa8e3 to
86a9f1e
Compare
|
Thank you! |
UPDATED 17/10/2026. Closes GH-23048.
[$a, $b] = [$b, $a];builds an array, fetches both elements back out of it and frees it. When the result is unused and the list is flat, unkeyed, without references or spread, with one value per target, this assigns the values directly:The values are evaluated in order before the first assignment, and a variable among them is copied first, so
[$a, $b] = [$b, $a]still swaps. Each target then takes its value, so a value the target does not keep (a setter that drops it, the same variable twice, a typed property that coerces it) is released at that assignment instead of after the last one, and a destructor that throws there stops the rest of the statement:[$a, $a, $b] = [new Boom, new stdClass, 42]no longer assigns$b. As agreed in the review, that order within one statement is not kept; UPGRADING says so.The expression lists of a
forloop get the same treatment, since their results are unused too. Nested lists stay as they are for now; without the copies they could take the same path, as a follow-up. Skipped or extra values stay too: the copy that would keep an untaken value alive isQM_ASSIGN+FREE, which the block pass turns intoCHECK_VAR, so the result would differ with and without opcache.CPU cycles per call of a function that does the statement once, change with this PR (negative = fewer cycles, faster); release builds with the flags of the official Docker image, GCC 13.3, i7-13700H, median of five alternating runs (
~: within the noise of master measured against itself):[$a, $b] = [$b, $a], ints$t = $a; $a = $b; $b = $t;, ints (not changed)[$a, $b, $c] = [$b, $c, $a][$a, $b] = [$b, $a + $b]for[$p[0], $p[1]] = [$p[1], $p[0]][$o->a, $o->b] = [$o->b, $o->a][$a, $b] = [1, 2]['x' => $a, 'y' => $b] = ['x' => $b, 'y' => $a](not covered)In instructions a swap goes from 922 to 467 per call, below the swap through a temporary variable (493). A loop of 300,000 swaps with opcache, wall time on the same laptop, median of five: 1.2 ms instead of 10.4 ms without the JIT, 0.32 ms instead of 8.5 ms with the tracing JIT and 0.25 ms instead of 8.4 ms with the function JIT. The loops it does not cover (skipped values, nested lists) compile to the same opcodes as on master and take 0–12% less time, which with the same opcodes is code placement, not this change. In two large
vendortrees (327,462 op_arrays) 56 statements are covered (counted with the previous version; which statements are covered did not change), so this is for the odd hot swap, not for applications.Verified: on 3,376 generated programs (two targets of 15 kinds, two values of 4 kinds, printing destructors, setters and hooks) the output is the same without opcache, with the optimizer, and with the tracing and function JIT; against master 273 of them print the same events in another order, all with a target that does not keep its value, and none differs otherwise. 96 hand-written behaviour cases give the same output as master in all four modes. Zend/tests and ext/opcache/tests pass on a debug build with ASan and UBSan, Zend/tests also with opcache and with the tracing JIT, and the tests of this PR in all four modes. A fatal compile error inside the new path (
[$a, f()] = [1, 2]) leaves its 48-byte array of znodes unfreed, which LeakSanitizer shows withUSE_ZEND_ALLOC=0;switch,matchand multi-catch leak their jump arrays on master the same way, and the Zend allocator releases them at the end of the request. The tests catch five of six deliberate mistakes I put into the new code (keys on the values only after I added a case for them); the sixth, letting nested list targets through, gives the same output as master on every nested case. Details and reproduction: https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/list-assign-from-array-literalPossible follow-ups: nested lists, and keyed lists with matching constant keys.