feat(compiler): enhance call argument handling and foreach iteration performance

- Add forceArrayArgs parameter to CallArgumentGenerator methods to properly handle
  named arguments with setValue method instead of set
- Implement direct foreach array target optimization to improve iteration
  performance by allowing direct assignment to variant variables
- Add comprehensive tests for dynamic call cache argument preservation
  including small, large, named, unpacked, reference, and exception arguments
- Add tests for dynamic property access with string, referenced-string, and
  converted names
- Add tests for foreach direct array targets preserving keys, values, references
- Update CI workflows to checkout PHPX from third_party directory instead of vendor
- Add smoke test for full static builds to verify module globals setup
- Enhance benchmark suite with additional dynamic call cases for different
  argument counts (zero, two, four args) to separate cache lookup from argument
  materialization costs
- Add case filtering option to property
master
韩天峰 2 days ago
parent dd4cd6d162
commit 5689170839
  1. 7
      .github/actions/unix-arm64-build/action.yml
  2. 43
      .github/smoke/full-static/main.php
  3. 8
      .github/smoke/full-static/project.yml
  4. 2
      .github/workflows/linux-arm64.yml
  5. 21
      .github/workflows/linux-x64.yml
  6. 2
      .github/workflows/macos-arm64.yml
  7. 11
      .github/workflows/windows-build.yml
  8. 4
      benchmark/dynamic-call/README.md
  9. 51
      benchmark/dynamic-call/benchmark.php
  10. 3
      benchmark/dynamic-call/run.php
  11. 4
      benchmark/property-access/README.md
  12. 66
      benchmark/property-access/benchmark.php
  13. 19
      benchmark/property-access/run.php
  14. 2
      phpunit/src/GeneratedCodeIndentationTest.php
  15. 35
      src/Generator/CallArgumentGenerator.php
  16. 64
      src/Parser/ForeachTrait.php
  17. 6
      src/Parser/MethodCallTrait.php
  18. 51
      tests/compiler/dynamic_call/call-cache-arguments.phpt
  19. 22
      tests/compiler/dynamic_call/call-magic-direct-safe.phpt
  20. 77
      tests/compiler/loop/foreach-direct-array-targets.phpt
  21. 48
      tests/compiler/object_property/dynamic-property-string-name.phpt

@ -24,6 +24,13 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- name: Checkout PHPX master
uses: actions/checkout@v4
with:
repository: swoole/phpx
ref: master
path: third_party/phpx
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:

@ -0,0 +1,43 @@
<?php
/**
* Smoke test for --full-static builds.
*
* The fully-static artifact embeds its own PHP runtime from the bundled SDK, so
* this exercises the parts of that runtime most likely to break: the module
* globals set up during php_module_startup (pcre was where a musl/glibc thread-
* local storage mismatch used to crash), plus the usual string/array/JSON paths.
*
* The output is a fixed token so both the x64 and arm64 workflows can compare
* it exactly, regardless of the matrix PHP version installed on the runner.
*/
function requireFullStatic(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function main(): void
{
// pcre reads its compile context from a ZTS module global allocated during
// php_module_startup; it is the first thing to break when the TLS layout is
// inconsistent with the C runtime that initialised the thread pointer.
requireFullStatic(preg_match('/^(\d+)\.(\d+)/', PHP_VERSION, $m) === 1, 'PHP_VERSION did not match');
$words = ['typephp', 'aot', 'static'];
sort($words);
requireFullStatic(implode(',', $words) === 'aot,static,typephp', 'sort() failed');
requireFullStatic(strtoupper('musl') === 'MUSL', 'strtoupper() failed');
requireFullStatic(json_encode(['ok' => true]) === '{"ok":true}', 'json_encode() failed');
$sum = 0;
for ($i = 0; $i < 100000; $i++) {
$sum += $i;
}
requireFullStatic($sum === 4999950000, 'loop result mismatch');
echo "full-static-smoke-ok\n";
}

@ -0,0 +1,8 @@
name: full-static-smoke
mode: bin
build-dir: build
output: full_static_smoke
cxx-std: c++17
sources:
- main.php

@ -26,7 +26,7 @@ jobs:
matrix: matrix:
php: ["8.4", "8.5"] php: ["8.4", "8.5"]
env: env:
PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx PHPX_HOME: ${{ github.workspace }}/third_party/phpx
steps: steps:
- name: Checkout TypePHP - name: Checkout TypePHP

@ -27,12 +27,19 @@ jobs:
matrix: matrix:
php: ["8.4", "8.5"] php: ["8.4", "8.5"]
env: env:
PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx PHPX_HOME: ${{ github.workspace }}/third_party/phpx
steps: steps:
- name: Checkout TypePHP - name: Checkout TypePHP
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Checkout PHPX master
uses: actions/checkout@v4
with:
repository: swoole/phpx
ref: master
path: third_party/phpx
- name: Checkout phpy - name: Checkout phpy
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@ -139,7 +146,11 @@ jobs:
retention-days: 7 retention-days: 7
path: | path: |
tpc tpc
vendor/swoole/phpx/lib/libphpx.so third_party/phpx/lib/libphpx.so
third_party/phpx/include/**
third_party/phpx/src/misc/**
third_party/phpx/thirdparty/mpdecimal/libmpdec/**
third_party/phpx/thirdparty/mpdecimal/libmpdec++/**
third_party/phpy/modules/phpy.so third_party/phpy/modules/phpy.so
phpunit: phpunit:
@ -153,7 +164,7 @@ jobs:
matrix: matrix:
php: ["8.4", "8.5"] php: ["8.4", "8.5"]
env: env:
PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx PHPX_HOME: ${{ github.workspace }}/third_party/phpx
steps: steps:
- name: Checkout TypePHP - name: Checkout TypePHP
@ -242,7 +253,7 @@ jobs:
matrix: matrix:
php: ["8.4", "8.5"] php: ["8.4", "8.5"]
env: env:
PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx PHPX_HOME: ${{ github.workspace }}/third_party/phpx
steps: steps:
- name: Checkout TypePHP - name: Checkout TypePHP
@ -349,7 +360,7 @@ jobs:
matrix: matrix:
php: ["8.4", "8.5"] php: ["8.4", "8.5"]
env: env:
PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx PHPX_HOME: ${{ github.workspace }}/third_party/phpx
NO_INTERACTION: 1 NO_INTERACTION: 1
REPORT_EXIT_STATUS: 1 REPORT_EXIT_STATUS: 1
TYPEPHP_PHPT_GENERATED_ARTIFACT_DIR: ${{ github.workspace }}/build/phpt-generated TYPEPHP_PHPT_GENERATED_ARTIFACT_DIR: ${{ github.workspace }}/build/phpt-generated

@ -26,7 +26,7 @@ jobs:
matrix: matrix:
php: ["8.4", "8.5"] php: ["8.4", "8.5"]
env: env:
PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx PHPX_HOME: ${{ github.workspace }}/third_party/phpx
steps: steps:
- name: Checkout TypePHP - name: Checkout TypePHP

@ -30,6 +30,13 @@ jobs:
- name: Checkout TypePHP - name: Checkout TypePHP
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Checkout PHPX master
uses: actions/checkout@v4
with:
repository: swoole/phpx
ref: master
path: third_party/phpx
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@ -75,7 +82,7 @@ jobs:
"PHP_VERSION=$phpVersion" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 "PHP_VERSION=$phpVersion" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
"PHP_THREAD_SAFETY=$threadSafety" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 "PHP_THREAD_SAFETY=$threadSafety" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
"PHP_HOME=$phpHome" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 "PHP_HOME=$phpHome" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
"PHPX_HOME=${{ github.workspace }}\vendor\swoole\phpx" | "PHPX_HOME=${{ github.workspace }}\third_party\phpx" |
Out-File $env:GITHUB_ENV -Append -Encoding utf8 Out-File $env:GITHUB_ENV -Append -Encoding utf8
"PHP_DEVEL_ARCHIVE=$archive" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 "PHP_DEVEL_ARCHIVE=$archive" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
"version=$phpVersion" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 "version=$phpVersion" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
@ -254,7 +261,7 @@ jobs:
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: with:
path: ${{ env.PHPX_HOME }}\thirdparty\mpdecimal\vcbuild\dist64 path: ${{ env.PHPX_HOME }}\thirdparty\mpdecimal\vcbuild\dist64
key: windows-2022-msvc-mpdecimal-x64-${{ hashFiles('vendor/swoole/phpx/thirdparty/mpdecimal/**') }} key: windows-2022-msvc-mpdecimal-x64-${{ hashFiles('third_party/phpx/thirdparty/mpdecimal/**') }}
- name: Build mpdecimal - name: Build mpdecimal
if: steps.cache-mpdecimal.outputs.cache-hit != 'true' if: steps.cache-mpdecimal.outputs.cache-hit != 'true'

@ -10,6 +10,10 @@ It also measures dynamic method names with a stable receiver, alternating
method names, and a fixed method name on changing receiver classes. Those method names, and a fixed method name on changing receiver classes. Those
cases require a class-entry guard in addition to a callable-name guard. cases require a class-entry guard in addition to a callable-name guard.
The monomorphic string-call cases cover zero, one, two, and four positional
arguments. This separates callable-cache lookup cost from argument
materialization cost and protects the small stack-argument fast path.
Run it from the repository root against a release PHP/PHPX build: Run it from the repository root against a release PHP/PHPX build:
```bash ```bash

@ -5,11 +5,26 @@ declare(strict_types=1);
const DYNAMIC_CALL_ITERATIONS = 1_000_000; const DYNAMIC_CALL_ITERATIONS = 1_000_000;
const DYNAMIC_CALL_ROUNDS = 5; const DYNAMIC_CALL_ROUNDS = 5;
function dynamicCallNoArgs(): int
{
return 1;
}
function dynamicCallAddOne(int $value): int function dynamicCallAddOne(int $value): int
{ {
return $value + 1; return $value + 1;
} }
function dynamicCallAddTwoArgs(int $left, int $right): int
{
return $left + $right;
}
function dynamicCallAddFourArgs(int $a, int $b, int $c, int $d): int
{
return $a + $b + $c + $d;
}
function dynamicCallAddTwo(int $value): int function dynamicCallAddTwo(int $value): int
{ {
return $value + 2; return $value + 2;
@ -100,6 +115,36 @@ function runMonomorphicStringCall(int $iterations): int
return $sum; return $sum;
} }
function runMonomorphicStringCallZeroArgs(int $iterations): int
{
$callback = 'dynamicCallNoArgs';
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += $callback();
}
return $sum;
}
function runMonomorphicStringCallTwoArgs(int $iterations): int
{
$callback = 'dynamicCallAddTwoArgs';
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += $callback($i, 1);
}
return $sum;
}
function runMonomorphicStringCallFourArgs(int $iterations): int
{
$callback = 'dynamicCallAddFourArgs';
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += $callback($i, 1, 2, 3);
}
return $sum;
}
function runAlternatingStringCall(int $iterations): int function runAlternatingStringCall(int $iterations): int
{ {
$sum = 0; $sum = 0;
@ -221,7 +266,10 @@ function runDynamicCallCase(string $case, int $iterations): int
{ {
return match ($case) { return match ($case) {
'direct' => runDirectCall($iterations), 'direct' => runDirectCall($iterations),
'string_monomorphic_zero' => runMonomorphicStringCallZeroArgs($iterations),
'string_monomorphic' => runMonomorphicStringCall($iterations), 'string_monomorphic' => runMonomorphicStringCall($iterations),
'string_monomorphic_two' => runMonomorphicStringCallTwoArgs($iterations),
'string_monomorphic_four' => runMonomorphicStringCallFourArgs($iterations),
'string_alternating' => runAlternatingStringCall($iterations), 'string_alternating' => runAlternatingStringCall($iterations),
'string_megamorphic' => runMegamorphicStringCall($iterations), 'string_megamorphic' => runMegamorphicStringCall($iterations),
'closure_monomorphic' => runMonomorphicClosureCall($iterations), 'closure_monomorphic' => runMonomorphicClosureCall($iterations),
@ -262,7 +310,10 @@ function main(): void
$selectedCase = getenv('DYNAMIC_CALL_CASE'); $selectedCase = getenv('DYNAMIC_CALL_CASE');
foreach ([ foreach ([
'direct', 'direct',
'string_monomorphic_zero',
'string_monomorphic', 'string_monomorphic',
'string_monomorphic_two',
'string_monomorphic_four',
'string_alternating', 'string_alternating',
'string_megamorphic', 'string_megamorphic',
'closure_monomorphic', 'closure_monomorphic',

@ -126,7 +126,10 @@ $typephp = parseDynamicCallResults(runDynamicCallCommand([$binary], $root, true,
$cases = [ $cases = [
'direct', 'direct',
'string_monomorphic_zero',
'string_monomorphic', 'string_monomorphic',
'string_monomorphic_two',
'string_monomorphic_four',
'string_alternating', 'string_alternating',
'string_megamorphic', 'string_megamorphic',
'closure_monomorphic', 'closure_monomorphic',

@ -13,4 +13,6 @@ php benchmark/property-access/run.php
To reuse an existing binary, add `--skip-build`. For local regression checks, To reuse an existing binary, add `--skip-build`. For local regression checks,
`--max-ratio=1.5` exits unsuccessfully when a dynamic read or write takes more `--max-ratio=1.5` exits unsuccessfully when a dynamic read or write takes more
than 1.5 times the corresponding Zend PHP result. than 1.5 times the corresponding Zend PHP result. Use
`--case=dynamic_write`, `--case=dynamic_read`, `--case=static_write`, or
`--case=static_read` to isolate one workload while profiling.

@ -107,6 +107,7 @@ function measure(callable $callback, int $operations): float
function main(): void function main(): void
{ {
global $benchmarkSink; global $benchmarkSink;
$selectedCase = getenv('PROPERTY_ACCESS_CASE');
$benchmarkSink = 0; $benchmarkSink = 0;
$iterations = 200000; $iterations = 200000;
$data = [ $data = [
@ -121,34 +122,41 @@ function main(): void
$static = new StaticPropertyEntity(); $static = new StaticPropertyEntity();
$operations = $iterations * 5; $operations = $iterations * 5;
$dynamicWrite = measure( if (!is_string($selectedCase) || $selectedCase === '' || $selectedCase === 'dynamic_write') {
function () use ($dynamic, $data, $iterations): int { $dynamicWrite = measure(
return runDynamicWrite($dynamic, $data, $iterations); function () use ($dynamic, $data, $iterations): int {
}, return runDynamicWrite($dynamic, $data, $iterations);
$operations, },
); $operations,
$staticWrite = measure( );
function () use ($static, $data, $iterations): int { printf("dynamic_write_ns=%.3f\n", $dynamicWrite);
return runStaticWrite($static, $data, $iterations); }
}, if (!is_string($selectedCase) || $selectedCase === '' || $selectedCase === 'static_write') {
$operations, $staticWrite = measure(
); function () use ($static, $data, $iterations): int {
$dynamicRead = measure( return runStaticWrite($static, $data, $iterations);
function () use ($dynamic, $properties, $iterations): int { },
return runDynamicRead($dynamic, $properties, $iterations); $operations,
}, );
$operations, printf("static_write_ns=%.3f\n", $staticWrite);
); }
$staticRead = measure( if (!is_string($selectedCase) || $selectedCase === '' || $selectedCase === 'dynamic_read') {
function () use ($static, $iterations): int { $dynamicRead = measure(
return runStaticRead($static, $iterations); function () use ($dynamic, $properties, $iterations): int {
}, return runDynamicRead($dynamic, $properties, $iterations);
$operations, },
); $operations,
);
printf("dynamic_write_ns=%.3f\n", $dynamicWrite); printf("dynamic_read_ns=%.3f\n", $dynamicRead);
printf("static_write_ns=%.3f\n", $staticWrite); }
printf("dynamic_read_ns=%.3f\n", $dynamicRead); if (!is_string($selectedCase) || $selectedCase === '' || $selectedCase === 'static_read') {
printf("static_read_ns=%.3f\n", $staticRead); $staticRead = measure(
function () use ($static, $iterations): int {
return runStaticRead($static, $iterations);
},
$operations,
);
printf("static_read_ns=%.3f\n", $staticRead);
}
echo 'checksum=', $benchmarkSink + $dynamic->sum($properties) + $static->sum(), "\n"; echo 'checksum=', $benchmarkSink + $dynamic->sum($properties) + $static->sum(), "\n";
} }

@ -8,10 +8,14 @@ $project = __DIR__ . '/project.yml';
$binary = __DIR__ . '/property_access'; $binary = __DIR__ . '/property_access';
$skipBuild = in_array('--skip-build', $argv, true); $skipBuild = in_array('--skip-build', $argv, true);
$maximumRatio = null; $maximumRatio = null;
$selectedCase = null;
foreach ($argv as $argument) { foreach ($argv as $argument) {
if (str_starts_with($argument, '--max-ratio=')) { if (str_starts_with($argument, '--max-ratio=')) {
$maximumRatio = (float) substr($argument, strlen('--max-ratio=')); $maximumRatio = (float) substr($argument, strlen('--max-ratio='));
} }
if (str_starts_with($argument, '--case=')) {
$selectedCase = substr($argument, strlen('--case='));
}
} }
/** /**
@ -82,20 +86,23 @@ if (!is_file($binary)) {
throw new RuntimeException('Benchmark binary does not exist: ' . $binary); throw new RuntimeException('Benchmark binary does not exist: ' . $binary);
} }
$benchmarkEnvironment = getenv();
if ($selectedCase !== null && $selectedCase !== '') {
$benchmarkEnvironment['PROPERTY_ACCESS_CASE'] = $selectedCase;
}
$php = parseResults(runCommand([ $php = parseResults(runCommand([
PHP_BINARY, PHP_BINARY,
'-d', '-d',
'opcache.enable_cli=0', 'opcache.enable_cli=0',
'-r', '-r',
'require ' . var_export($source, true) . '; main();', 'require ' . var_export($source, true) . '; main();',
], $root, true)); ], $root, true, $benchmarkEnvironment));
$typephpEnvironment = null; $typephpEnvironment = $benchmarkEnvironment;
if (PHP_OS_FAMILY !== 'Windows') { if (PHP_OS_FAMILY !== 'Windows') {
$phpxHome = getenv('PHPX_HOME'); $phpxHome = getenv('PHPX_HOME');
if (!is_string($phpxHome) || $phpxHome === '') { if (!is_string($phpxHome) || $phpxHome === '') {
$phpxHome = $root . '/vendor/swoole/phpx'; $phpxHome = $root . '/vendor/swoole/phpx';
} }
$typephpEnvironment = getenv();
$loaderVariable = PHP_OS_FAMILY === 'Darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH'; $loaderVariable = PHP_OS_FAMILY === 'Darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
$existingPath = $typephpEnvironment[$loaderVariable] ?? ''; $existingPath = $typephpEnvironment[$loaderVariable] ?? '';
$typephpEnvironment[$loaderVariable] = $phpxHome . '/lib' $typephpEnvironment[$loaderVariable] = $phpxHome . '/lib'
@ -106,7 +113,11 @@ $typephp = parseResults(runCommand([$binary], $root, true, $typephpEnvironment))
echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n"; echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n";
echo "------------------------------------------------------------\n"; echo "------------------------------------------------------------\n";
$failed = false; $failed = false;
foreach (['dynamic_write_ns', 'dynamic_read_ns', 'static_write_ns', 'static_read_ns'] as $metric) { $metrics = ['dynamic_write_ns', 'dynamic_read_ns', 'static_write_ns', 'static_read_ns'];
if ($selectedCase !== null && $selectedCase !== '') {
$metrics = [$selectedCase . '_ns'];
}
foreach ($metrics as $metric) {
if (!isset($php[$metric], $typephp[$metric])) { if (!isset($php[$metric], $typephp[$metric])) {
throw new RuntimeException('Missing benchmark metric: ' . $metric); throw new RuntimeException('Missing benchmark metric: ' . $metric);
} }

@ -18,7 +18,7 @@ class GeneratedCodeIndentationTest extends \PHPUnit\Framework\TestCase
$code = file_get_contents($cppFile); $code = file_get_contents($cppFile);
$this->assertIsString($code); $this->assertIsString($code);
$this->assertStringContainsString( $this->assertStringContainsString(
"\t\twhile (tmp_var_0.next()) {\n\t\t\titem = tmp_var_0.value();", "\t\twhile (tmp_var_0.nextValue(item)) {",
$code, $code,
); );
$this->assertStringContainsString( $this->assertStringContainsString(

@ -471,7 +471,9 @@ trait CallArgumentGenerator
$this->context->beforeStmtLines[] = $namedArgsArray . '.set(' . $this->getLiteralString($arg->name->name) . ', ' . $value . ');'; $this->context->beforeStmtLines[] = $namedArgsArray . '.set(' . $this->getLiteralString($arg->name->name) . ', ' . $value . ');';
} else { } else {
$arrayArgs = $this->ensureCallArrayArgs($arrayArgsVar, $list_args); $arrayArgs = $this->ensureCallArrayArgs($arrayArgsVar, $list_args);
$this->context->beforeStmtLines[] = $arrayArgs . '.set(' . $this->getLiteralString($arg->name->name) . ', ' . $value . ');'; $method = $forceArrayArgs ? 'setValue' : 'set';
$this->context->beforeStmtLines[] = $arrayArgs . '.' . $method . '('
. $this->getLiteralString($arg->name->name) . ', ' . $value . ');';
} }
continue; continue;
} }
@ -495,13 +497,13 @@ trait CallArgumentGenerator
} }
} }
$value = $this->wrapScopedCallbackArg($arg, $this->parseCallArgValue($arg)); $value = $this->wrapScopedCallbackArg($arg, $this->parseCallArgValue($arg));
$this->addPositionalCallArg($value, $arrayArgsVar, $list_args); $this->addPositionalCallArg($value, $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
if ($this->isVarExpr($arg->value)) { if ($this->isVarExpr($arg->value)) {
$name = $this->parseIdentifier($arg->value); $name = $this->parseIdentifier($arg->value);
if ($byRef) { if ($byRef) {
$this->addPositionalCallArg($this->parseArgRefVar($arg, $name), $arrayArgsVar, $list_args); $this->addPositionalCallArg($this->parseArgRefVar($arg, $name), $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
if (!$this->hasVar($name)) { if (!$this->hasVar($name)) {
@ -509,7 +511,7 @@ trait CallArgumentGenerator
} }
} elseif ($this->isPropertyFetch($arg->value)) { } elseif ($this->isPropertyFetch($arg->value)) {
if ($byRef) { if ($byRef) {
$this->addPositionalCallArg($this->emitDynamicPropertyFetchRef($arg->value, $arg), $arrayArgsVar, $list_args); $this->addPositionalCallArg($this->emitDynamicPropertyFetchRef($arg->value, $arg), $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
if ($this->isVarExpr($arg->value->var)) { if ($this->isVarExpr($arg->value->var)) {
@ -526,9 +528,9 @@ trait CallArgumentGenerator
if ($byRef) { if ($byRef) {
$ref = $this->addTmpVar(Type::REF); $ref = $this->addTmpVar(Type::REF);
$this->context->beforeStmtLines[] = $ref . ' = ' . $globalVar . '.toReference();'; $this->context->beforeStmtLines[] = $ref . ' = ' . $globalVar . '.toReference();';
$this->addPositionalCallArg('&' . $ref, $arrayArgsVar, $list_args); $this->addPositionalCallArg('&' . $ref, $arrayArgsVar, $list_args, $forceArrayArgs);
} else { } else {
$this->addPositionalCallArg($globalVar, $arrayArgsVar, $list_args); $this->addPositionalCallArg($globalVar, $arrayArgsVar, $list_args, $forceArrayArgs);
} }
continue; continue;
} }
@ -539,7 +541,7 @@ trait CallArgumentGenerator
if ($arg->value->dim === null) { if ($arg->value->dim === null) {
$this->fatalError($arg, 'Array dimension must be a constant expression'); $this->fatalError($arg, 'Array dimension must be a constant expression');
} }
$this->addPositionalCallArg($array . '.itemRef(' . $this->identifierToStr($arg->value->dim) . ')', $arrayArgsVar, $list_args); $this->addPositionalCallArg($array . '.itemRef(' . $this->identifierToStr($arg->value->dim) . ')', $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
} elseif ($this->isReferenceWrapperCall($arg->value)) { } elseif ($this->isReferenceWrapperCall($arg->value)) {
@ -547,12 +549,12 @@ trait CallArgumentGenerator
if ($this->isVarExpr($inner)) { if ($this->isVarExpr($inner)) {
$name = $this->parseVariable($inner); $name = $this->parseVariable($inner);
$arg->value = $inner; $arg->value = $inner;
$this->addPositionalCallArg($this->parseArgRefVar($arg, $name), $arrayArgsVar, $list_args); $this->addPositionalCallArg($this->parseArgRefVar($arg, $name), $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
$expr = $this->expandRefvalExpr($inner, $arg); $expr = $this->expandRefvalExpr($inner, $arg);
if ($expr !== null) { if ($expr !== null) {
$this->addPositionalCallArg($expr, $arrayArgsVar, $list_args); $this->addPositionalCallArg($expr, $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
$this->fatalError($arg, 'The refval function only accepts a variable, array element, or object property'); $this->fatalError($arg, 'The refval function only accepts a variable, array element, or object property');
@ -564,12 +566,12 @@ trait CallArgumentGenerator
$tmpRef = $this->genTmpVarName(); $tmpRef = $this->genTmpVarName();
$this->addLocalVar($tmpRef, Type::REF); $this->addLocalVar($tmpRef, Type::REF);
$this->context->beforeStmtLines[] = $tmpRef . ' = ' . $this->parseChainedExpr($arg->value, self::OP_REFVAL) . ';'; $this->context->beforeStmtLines[] = $tmpRef . ' = ' . $this->parseChainedExpr($arg->value, self::OP_REFVAL) . ';';
$this->addPositionalCallArg('&' . $tmpRef, $arrayArgsVar, $list_args); $this->addPositionalCallArg('&' . $tmpRef, $arrayArgsVar, $list_args, $forceArrayArgs);
continue; continue;
} }
} }
$value = $this->parseCallArgValue($arg); $value = $this->parseCallArgValue($arg);
$this->addPositionalCallArg($value, $arrayArgsVar, $list_args); $this->addPositionalCallArg($value, $arrayArgsVar, $list_args, $forceArrayArgs);
} }
if ($argsVar !== null) { if ($argsVar !== null) {
@ -635,10 +637,15 @@ trait CallArgumentGenerator
return $namedArgsVar; return $namedArgsVar;
} }
protected function addPositionalCallArg(string $value, ?string $arrayArgsVar, array &$listArgs): void protected function addPositionalCallArg(
{ string $value,
?string $arrayArgsVar,
array &$listArgs,
bool $appendByValue = false,
): void {
if ($arrayArgsVar !== null) { if ($arrayArgsVar !== null) {
$this->context->beforeStmtLines[] = $arrayArgsVar . '.append(' . $value . ');'; $method = $appendByValue ? 'appendValue' : 'append';
$this->context->beforeStmtLines[] = $arrayArgsVar . '.' . $method . '(' . $value . ');';
} else { } else {
$listArgs[] = $value; $listArgs[] = $value;
} }

@ -120,23 +120,69 @@ trait ForeachTrait
return $this->getIndent() . $valueVar . ' = ' . $valueExpr . ';' . PHP_EOL; return $this->getIndent() . $valueVar . ' = ' . $valueExpr . ';' . PHP_EOL;
} }
protected function parseForeachIterable(Foreach_ $node, string $iterableVar): string /**
* Return a foreach target that can be filled directly by ForeachIterator.
*
* The combined iterator API deliberately accepts only ordinary Variant
* variables. References, native scalar locals, properties, array offsets,
* and destructuring retain the general assignment path below.
*/
protected function parseDirectForeachTarget(Foreach_ $node, Expr $target): ?string
{
if (!$this->isVarExpr($target)) {
return null;
}
$name = $this->parseIdentifier($target);
if ($this->hasVar($name) && $this->getVarType($name) !== Type::VAR) {
return null;
}
$this->checkVar($node, $name);
return $name;
}
protected function parseForeachIterable(
Foreach_ $node,
string $iterableVar,
bool $allowDirectArrayTargets = false,
): string
{ {
$iterator = $this->genTmpVarName(); $iterator = $this->genTmpVarName();
$byRef = $node->byRef ? 'true' : 'false'; $byRef = $node->byRef ? 'true' : 'false';
$scope = $this->class ? $this->getLocalClassEntryPtr($this->getFullClassName()) : 'nullptr'; $scope = $this->class ? $this->getLocalClassEntryPtr($this->getFullClassName()) : 'nullptr';
$directValue = null;
$directKey = null;
if ($allowDirectArrayTargets && !$node->byRef) {
$directValue = $this->parseDirectForeachTarget($node, $node->valueVar);
if ($directValue !== null && $node->keyVar !== null) {
$directKey = $this->parseDirectForeachTarget($node, $node->keyVar);
if ($directKey === null) {
$directValue = null;
}
}
}
$code = '{' . PHP_EOL; $code = '{' . PHP_EOL;
$this->indentLevel++; $this->indentLevel++;
$code .= $this->getIndent() . "php::ForeachIterator $iterator{{$iterableVar}, $byRef, $scope};" . PHP_EOL; $code .= $this->getIndent() . "php::ForeachIterator $iterator{{$iterableVar}, $byRef, $scope};" . PHP_EOL;
$code .= $this->getIndent() . "while ($iterator.next()) {" . PHP_EOL; if ($directValue !== null) {
$next = $directKey === null
? "$iterator.nextValue($directValue)"
: "$iterator.nextKeyValue($directKey, $directValue)";
} else {
$next = "$iterator.next()";
}
$code .= $this->getIndent() . "while ($next) {" . PHP_EOL;
$this->indentLevel++; $this->indentLevel++;
$code .= $this->parseForeachKeyAssignment($node, $iterator . '.key()'); if ($directValue === null) {
$code .= $this->parseForeachValueAssignment( $code .= $this->parseForeachKeyAssignment($node, $iterator . '.key()');
$node, $code .= $this->parseForeachValueAssignment(
$iterator . '.value()', $node,
$iterator . '.assignValueRef', $iterator . '.value()',
); $iterator . '.assignValueRef',
);
}
$body = $this->parseForeachBody($node); $body = $this->parseForeachBody($node);
$this->indentLevel--; $this->indentLevel--;
@ -203,7 +249,7 @@ trait ForeachTrait
|| $type === Type::OBJECT || $type === Type::OBJECT
|| ($node->byRef && ($type === Type::VAR || $type === Type::REF)) || ($node->byRef && ($type === Type::VAR || $type === Type::REF))
) { ) {
return $this->parseForeachIterable($node, $name); return $this->parseForeachIterable($node, $name, $type === Type::ARRAY);
} elseif ($this->isStdContainerType($type)) { } elseif ($this->isStdContainerType($type)) {
return $this->parseForeachStdContainer($node); return $this->parseForeachStdContainer($node);
} }

@ -358,7 +358,11 @@ trait MethodCallTrait
forceArrayArgs: true, forceArrayArgs: true,
); );
return self::PREFIX . $nativeFunc . '(' . $object . ', ' . $method . ', ' . $arguments . ')'; // The argument array is a compiler-owned temporary used only by this
// direct call. Transfer its zval instead of incrementing/decrementing
// the array refcount at the generated __call() boundary.
return self::PREFIX . $nativeFunc . '('
. $object . ', ' . $method . ', std::move(' . $arguments . '))';
} }
protected function parseNativeMethodCall(string $object, string $nativeFunc, array $args): string protected function parseNativeMethodCall(string $object, string $nativeFunc, array $args): string

@ -0,0 +1,51 @@
--TEST--
Dynamic call cache preserves small, large, named, unpacked, reference, and exception arguments
--FILE--
<?php
function cached_sum(int $a, int $b, int $c, int $d, int $e = 0): int
{
return $a + $b + $c + $d + $e;
}
function cached_increment(int &$value): int
{
return ++$value;
}
function cached_throw(string $message): never
{
throw new RuntimeException($message);
}
function main(): void
{
$sum = 'cached_sum';
var_dump($sum(1, 2, 3, 4));
var_dump($sum(1, 2, 3, 4, 5));
var_dump($sum(d: 4, c: 3, b: 2, a: 1));
$arguments = [1, 2, 3, 4, 5];
var_dump($sum(...$arguments));
$increment = 'cached_increment';
$value = 10;
var_dump($increment(refval($value)));
var_dump($value);
$throw = 'cached_throw';
try {
$throw('cached failure');
} catch (RuntimeException $exception) {
echo $exception->getMessage(), "\n";
}
}
?>
--EXPECT--
int(10)
int(15)
int(10)
int(15)
int(11)
int(11)
cached failure

@ -22,6 +22,15 @@ class RuntimeMethod extends DirectMagic
} }
} }
final class MutatingMagic
{
public function __call(string $name, array $arguments): mixed
{
$arguments[0] = 'changed-in-call';
return $arguments;
}
}
function callFromDeclaredBase(DirectMagic $object): mixed function callFromDeclaredBase(DirectMagic $object): mixed
{ {
return $object->existing(); return $object->existing();
@ -39,6 +48,14 @@ function main(): void
// A declared base type is not an exact runtime type. The compiler must // A declared base type is not an exact runtime type. The compiler must
// retain Zend dispatch so a subclass's real method wins over __call(). // retain Zend dispatch so a subclass's real method wins over __call().
var_dump(callFromDeclaredBase(new RuntimeMethod())); var_dump(callFromDeclaredBase(new RuntimeMethod()));
// Zend constructs __call()'s argument array by value. A source reference
// must not leak into that array, including on the direct compiled path.
$source = 'original';
$reference = &$source;
$mutating = new MutatingMagic();
var_dump($mutating->missing($reference));
var_dump($source);
} }
?> ?>
--EXPECT-- --EXPECT--
@ -67,3 +84,8 @@ array(2) {
} }
} }
string(14) "runtime-method" string(14) "runtime-method"
array(1) {
[0]=>
string(15) "changed-in-call"
}
string(8) "original"

@ -0,0 +1,77 @@
--TEST--
foreach direct array targets preserve keys, values, references, and assignment order
--FILE--
<?php
function collect(array $values): array
{
$result = [];
foreach ($values as $key => $value) {
$result[] = $key . ':' . $value;
}
return $result;
}
function valuesOnly(array $values): array
{
$result = [];
foreach ($values as $value) {
$result[] = $value;
}
return $result;
}
function sameTarget(array $values): mixed
{
$item = null;
foreach ($values as $item => $item) {
}
return $item;
}
function main(): void
{
var_dump(collect([2 => 'two', 'name' => 'value']));
var_dump(valuesOnly([10, 20]));
var_dump(sameTarget([7 => 'last-value']));
$source = 10;
$values = [&$source];
foreach ($values as $value) {
$value = 99;
}
var_dump($source, $value);
$snapshot = ['a' => 1, 'b' => 2];
$seen = [];
foreach ($snapshot as $key => $value) {
$seen[] = $key . ':' . $value;
unset($snapshot[$key]);
}
var_dump($seen, $snapshot);
}
?>
--EXPECT--
array(2) {
[0]=>
string(5) "2:two"
[1]=>
string(10) "name:value"
}
array(2) {
[0]=>
int(10)
[1]=>
int(20)
}
string(10) "last-value"
int(10)
int(99)
array(2) {
[0]=>
string(3) "a:1"
[1]=>
string(3) "b:2"
}
array(0) {
}

@ -0,0 +1,48 @@
--TEST--
dynamic property access preserves string, referenced-string, and converted names
--FILE--
<?php
final class DynamicNameBag
{
private array $values = [];
public function __get(string $name): mixed
{
return $this->values[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->values[$name] = $value;
}
}
function readName(DynamicNameBag $bag, mixed $name): mixed
{
return $bag->{$name};
}
function writeName(DynamicNameBag $bag, mixed $name, mixed $value): void
{
$bag->{$name} = $value;
}
function main(): void
{
$bag = new DynamicNameBag();
$name = 'answer';
writeName($bag, $name, 42);
var_dump(readName($bag, $name));
$alias = &$name;
var_dump(readName($bag, $alias));
writeName($bag, 7, 'seven');
var_dump(readName($bag, 7));
}
?>
--EXPECT--
int(42)
int(42)
string(5) "seven"
Loading…
Cancel
Save