[php-src] PR #24180: Zend: Compile a list assignment from an array literal without the array
| From: | ArtUkrainskiy | Date: | Wed, 07 Oct 2026 19:28:41 +0000 |
| Subject: | [php-src] PR #24180: Zend: Compile a list assignment from an array literal without the array | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-39240@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/24180
Author: ArtUkrainskiy
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)
T4 = COPY_TMP T2
ASSIGN CV0($a) T4
T6 = COPY_TMP T3
ASSIGN CV1($b) T6
FREE T2
FREE T3
```
That is the shape from the issue plus a copy per target. Without the copies the four opcodes are 467
instructions instead of 523, but the array held every value until after the last assignment, and
dropping that is observable: a setter that discards the value, two targets that are references to
each other, and a destructor that throws — on master [$a, $a, $b] = [new Boom, new stdClass,
42] still assigns $b, without the copies the exception cuts the statement short.
I tried keeping the direct assignment for plain variables, but whether a variable still holds its
value at the end of the statement is a runtime question (references, user code in a later
target's expression), so every target gets a copy and the values are freed where the array was.
With that I found no remaining difference, destruction order under exceptions included.
The expression lists of a for loop get the same treatment, since their results are
unused too. Nested lists stay as they are: the inner list needs an owner whose destruction order
matches the old fetches, and a COPY_TMP freed later can't be one — its live
range is computed for the ?? pattern. 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.
Release builds, perf stat instructions per statement:
| statement | instructions |
|---|---|
| [$a, $b] = [$b, $a] | 922 → 523 |
| [$a, $b, $c] = [$b, $c, $a] | 1,119 → 629 |
| ten Fibonacci steps [$a, $b] = [$b, ($a + $b) % M] | 6,114 → 2,064 |
| [$p[0], $p[1]] = [$p[1], $p[0]] | 1,290 → 879 |
| swap through a temporary variable, for reference | 493 |
A loop of 300,000 swaps with opcache: 5.1 → 1.1 ms, with the tracing JIT 3.8 → 0.5 ms. In two
large vendor trees (327,462 op_arrays) 56 statements change and nothing else does, so
this is for the odd hot swap, not for applications.
Verified against master: 97 behaviour cases and a sweep of 3,376 generated programs with printing
destructors and setters, identical output on release and debug builds, with opcache and both JITs.
Test expectations are generated on master; the lifetime test also runs with
opcache.optimization_level=-1. Details and reproduction: https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/list-assign-from-array-literal
Possible follow-ups: keyed lists with matching constant keys, and an optimizer rule dropping
COPY_TMP/FREE for values known not to be refcounted, which would give
typed swaps the four-opcode shape.