Skip to content

Zend: Compile a list assignment from an array literal without the array - #24180

Merged
arnaud-lb merged 1 commit into
php:masterfrom
ArtUkrainskiy:destructure-array-literal
Oct 19, 2026
Merged

arnaud-lb merged 1 commit into
php:masterfrom
ArtUkrainskiy:destructure-array-literal

Conversation

@ArtUkrainskiy

@ArtUkrainskiy ArtUkrainskiy commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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:

T2 = QM_ASSIGN CV1($b)
T3 = QM_ASSIGN CV0($a)
ASSIGN CV0($a) T2
ASSIGN CV1($b) T3

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 for loop 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 is QM_ASSIGN + FREE, which the block pass turns into CHECK_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):

call master, CPU cycles per call (GCC 13.3) GCC 13.3
[$a, $b] = [$b, $a], ints 173 −47%
$t = $a; $a = $b; $b = $t;, ints (not changed) 95 0% ~
[$a, $b, $c] = [$b, $c, $a] 209 −49%
10 × [$a, $b] = [$b, $a + $b] 1,212 −71%
10 × the same in the step list of a for 1,224 −72%
[$p[0], $p[1]] = [$p[1], $p[0]] 234 −36%
[$o->a, $o->b] = [$o->b, $o->a] 347 −27%
[$a, $b] = [1, 2] 74 −28%
['x' => $a, 'y' => $b] = ['x' => $b, 'y' => $a] (not covered) 191 −2%

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 vendor trees (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 with USE_ZEND_ALLOC=0; switch, match and 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-literal

Possible follow-ups: nested lists, and keyed lists with matching constant keys.

@ArtUkrainskiy
ArtUkrainskiy force-pushed the destructure-array-literal branch 2 times, most recently from eb63ed6 to 5bfa8e3 Compare October 7, 2026 20:39
@ArtUkrainskiy
ArtUkrainskiy marked this pull request as ready for review October 7, 2026 21:47
@ArtUkrainskiy
ArtUkrainskiy requested a review from dstogov as a code owner October 7, 2026 21:47
0001 CV1($b) = RECV 2
0002 T2 = QM_ASSIGN CV1($b)
0003 T3 = QM_ASSIGN CV0($a)
0004 T4 = COPY_TMP T2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IMHO we shouldn't care about destructor order here. It's observable, but no sane program should rely on intra-expression ordering. If @ndossche @TimWolla agree, we should drop the copies. Otherwise, we should add a comment about this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I agree with Nora.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@TimWolla
TimWolla requested a review from arnaud-lb October 9, 2026 07:17
[$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).
@ArtUkrainskiy
ArtUkrainskiy force-pushed the destructure-array-literal branch from 5bfa8e3 to 86a9f1e Compare October 17, 2026 16:05
@arnaud-lb
arnaud-lb merged commit 6277778 into php:master Oct 19, 2026
16 of 18 checks passed
@arnaud-lb

Copy link
Copy Markdown
Member

Thank you!

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.

Optimize destructuring of array literals to elide intermediate array

4 participants