parent
1c16639418
commit
683a7c6ec0
16 changed files with 715 additions and 4 deletions
@ -0,0 +1,3 @@ |
||||
/build/ |
||||
/dynamic_call |
||||
/*.rsp |
||||
@ -0,0 +1,28 @@ |
||||
# Dynamic call benchmark |
||||
|
||||
This benchmark compares direct calls with the runtime callable forms handled |
||||
by PHPX. It deliberately separates stable call sites from alternating and |
||||
megamorphic sites. TypePHP's function-call cache keeps one name inline and |
||||
promotes polymorphic sites to a request-local table. Its method-call cache is |
||||
monomorphic and disables itself after observing a different class or name. |
||||
|
||||
It also measures dynamic method names with a stable receiver, alternating |
||||
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. |
||||
|
||||
Run it from the repository root against a release PHP/PHPX build: |
||||
|
||||
```bash |
||||
PHPX_HOME=../phpx PHP_BIN=/opt/php-8.5-nts/bin/php php benchmark/dynamic-call/run.php |
||||
``` |
||||
|
||||
The TypePHP binary is built with `-O3` and LTO. `PHP_BIN` selects both the Zend |
||||
PHP baseline and, by default, the PHP executable used to run the compiler. |
||||
`TPC_PHP_BIN` may override the latter, but the runner rejects different PHP |
||||
versions, ZTS/debug modes, or integer widths. PHPX must also be a Release build |
||||
for that same PHP ABI. Add `--skip-build` to reuse the binary or |
||||
`--case=<name>` to measure one workload while profiling. |
||||
|
||||
Reported values are the best of five rounds after two warm-up rounds. The |
||||
checksum must match between PHP and TypePHP; absolute timing is intentionally |
||||
not used as a pass/fail condition. |
||||
@ -0,0 +1,284 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
const DYNAMIC_CALL_ITERATIONS = 1_000_000; |
||||
const DYNAMIC_CALL_ROUNDS = 5; |
||||
|
||||
function dynamicCallAddOne(int $value): int |
||||
{ |
||||
return $value + 1; |
||||
} |
||||
|
||||
function dynamicCallAddTwo(int $value): int |
||||
{ |
||||
return $value + 2; |
||||
} |
||||
|
||||
function dynamicCallAddThree(int $value): int |
||||
{ |
||||
return $value + 3; |
||||
} |
||||
|
||||
function dynamicCallAddFour(int $value): int |
||||
{ |
||||
return $value + 4; |
||||
} |
||||
|
||||
function dynamicCallAddFive(int $value): int |
||||
{ |
||||
return $value + 5; |
||||
} |
||||
|
||||
function dynamicCallAddSix(int $value): int |
||||
{ |
||||
return $value + 6; |
||||
} |
||||
|
||||
function dynamicCallAddSeven(int $value): int |
||||
{ |
||||
return $value + 7; |
||||
} |
||||
|
||||
function dynamicCallAddEight(int $value): int |
||||
{ |
||||
return $value + 8; |
||||
} |
||||
|
||||
final class DynamicCallTarget |
||||
{ |
||||
public static function addOne(int $value): int |
||||
{ |
||||
return $value + 1; |
||||
} |
||||
|
||||
public function addTwo(int $value): int |
||||
{ |
||||
return $value + 2; |
||||
} |
||||
|
||||
public function hitOne(int $value): int |
||||
{ |
||||
return $value + 1; |
||||
} |
||||
|
||||
public function hitTwo(int $value): int |
||||
{ |
||||
return $value + 2; |
||||
} |
||||
|
||||
public function __invoke(int $value): int |
||||
{ |
||||
return $value + 3; |
||||
} |
||||
} |
||||
|
||||
final class DynamicCallAlternateTarget |
||||
{ |
||||
public function hitOne(int $value): int |
||||
{ |
||||
return $value + 1; |
||||
} |
||||
} |
||||
|
||||
function runDirectCall(int $iterations): int |
||||
{ |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += dynamicCallAddOne($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runMonomorphicStringCall(int $iterations): int |
||||
{ |
||||
$callback = 'dynamicCallAddOne'; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runAlternatingStringCall(int $iterations): int |
||||
{ |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$callback = ($i & 1) === 0 ? 'dynamicCallAddOne' : 'dynamicCallAddTwo'; |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runMegamorphicStringCall(int $iterations): int |
||||
{ |
||||
$callbacks = [ |
||||
'dynamicCallAddOne', |
||||
'dynamicCallAddTwo', |
||||
'dynamicCallAddThree', |
||||
'dynamicCallAddFour', |
||||
'dynamicCallAddFive', |
||||
'dynamicCallAddSix', |
||||
'dynamicCallAddSeven', |
||||
'dynamicCallAddEight', |
||||
]; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$callback = $callbacks[($i * 5 + 3) & 7]; |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runMonomorphicClosureCall(int $iterations): int |
||||
{ |
||||
$callback = static fn (int $value): int => $value + 1; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runAlternatingClosureCall(int $iterations): int |
||||
{ |
||||
$first = static fn (int $value): int => $value + 1; |
||||
$second = static fn (int $value): int => $value + 2; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$callback = ($i & 1) === 0 ? $first : $second; |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runStaticMethodStringCall(int $iterations): int |
||||
{ |
||||
$callback = 'DynamicCallTarget::addOne'; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runObjectMethodArrayCall(int $iterations): int |
||||
{ |
||||
$target = new DynamicCallTarget(); |
||||
$callback = [$target, 'addTwo']; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runInvokableObjectCall(int $iterations): int |
||||
{ |
||||
$callback = new DynamicCallTarget(); |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += $callback($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runMonomorphicMethodNameCall(int $iterations): int |
||||
{ |
||||
$target = new DynamicCallTarget(); |
||||
$method = 'hitOne'; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$sum += $target->$method($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runAlternatingMethodNameCall(int $iterations): int |
||||
{ |
||||
$target = new DynamicCallTarget(); |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$method = ($i & 1) === 0 ? 'hitOne' : 'hitTwo'; |
||||
$sum += $target->$method($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runPolymorphicMethodReceiverCall(int $iterations): int |
||||
{ |
||||
$targets = [new DynamicCallTarget(), new DynamicCallAlternateTarget()]; |
||||
$method = 'hitOne'; |
||||
$sum = 0; |
||||
for ($i = 0; $i < $iterations; $i++) { |
||||
$target = $targets[$i & 1]; |
||||
$sum += $target->$method($i); |
||||
} |
||||
return $sum; |
||||
} |
||||
|
||||
function runDynamicCallCase(string $case, int $iterations): int |
||||
{ |
||||
return match ($case) { |
||||
'direct' => runDirectCall($iterations), |
||||
'string_monomorphic' => runMonomorphicStringCall($iterations), |
||||
'string_alternating' => runAlternatingStringCall($iterations), |
||||
'string_megamorphic' => runMegamorphicStringCall($iterations), |
||||
'closure_monomorphic' => runMonomorphicClosureCall($iterations), |
||||
'closure_alternating' => runAlternatingClosureCall($iterations), |
||||
'static_method_string' => runStaticMethodStringCall($iterations), |
||||
'object_method_array' => runObjectMethodArrayCall($iterations), |
||||
'invokable_object' => runInvokableObjectCall($iterations), |
||||
'method_name_monomorphic' => runMonomorphicMethodNameCall($iterations), |
||||
'method_name_alternating' => runAlternatingMethodNameCall($iterations), |
||||
'method_receiver_polymorphic' => runPolymorphicMethodReceiverCall($iterations), |
||||
default => throw new RuntimeException("Unknown benchmark case: {$case}"), |
||||
}; |
||||
} |
||||
|
||||
function measureDynamicCallCase(string $case): array |
||||
{ |
||||
for ($warmup = 0; $warmup < 2; $warmup++) { |
||||
runDynamicCallCase($case, 1_000); |
||||
} |
||||
|
||||
$best = 1.0e30; |
||||
$bestResult = 0; |
||||
for ($round = 0; $round < DYNAMIC_CALL_ROUNDS; $round++) { |
||||
$start = hrtime(true); |
||||
$result = runDynamicCallCase($case, DYNAMIC_CALL_ITERATIONS); |
||||
$elapsed = hrtime(true) - $start; |
||||
if ($elapsed < $best) { |
||||
$best = $elapsed; |
||||
$bestResult = $result; |
||||
} |
||||
} |
||||
|
||||
return [$best / DYNAMIC_CALL_ITERATIONS, $bestResult]; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$selectedCase = getenv('DYNAMIC_CALL_CASE'); |
||||
foreach ([ |
||||
'direct', |
||||
'string_monomorphic', |
||||
'string_alternating', |
||||
'string_megamorphic', |
||||
'closure_monomorphic', |
||||
'closure_alternating', |
||||
'static_method_string', |
||||
'object_method_array', |
||||
'invokable_object', |
||||
'method_name_monomorphic', |
||||
'method_name_alternating', |
||||
'method_receiver_polymorphic', |
||||
] as $case) { |
||||
if (is_string($selectedCase) && $selectedCase !== '' && $selectedCase !== $case) { |
||||
continue; |
||||
} |
||||
[$nanoseconds, $result] = measureDynamicCallCase($case); |
||||
printf("%s_ns=%.3f\n", $case, $nanoseconds); |
||||
printf("checksum_%s=%d\n", $case, $result); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@ |
||||
name: dynamic_call_benchmark |
||||
mode: bin |
||||
optimize: 3 |
||||
lto: true |
||||
build-dir: build |
||||
output: dynamic_call |
||||
sources: |
||||
- benchmark.php |
||||
@ -0,0 +1,162 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
$root = dirname(__DIR__, 2); |
||||
$source = __DIR__ . '/benchmark.php'; |
||||
$project = __DIR__ . '/project.yml'; |
||||
$binary = __DIR__ . '/dynamic_call' . (PHP_OS_FAMILY === 'Windows' ? '.exe' : ''); |
||||
$skipBuild = in_array('--skip-build', $argv, true); |
||||
$selectedCase = null; |
||||
foreach ($argv as $argument) { |
||||
if (str_starts_with($argument, '--case=')) { |
||||
$selectedCase = substr($argument, strlen('--case=')); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param list<string> $command |
||||
* @param array<string, string>|null $environment |
||||
*/ |
||||
function runDynamicCallCommand(array $command, string $cwd, bool $capture, ?array $environment = null): string |
||||
{ |
||||
$stdout = $capture ? ['pipe', 'w'] : STDOUT; |
||||
$stderr = $capture ? ['pipe', 'w'] : STDERR; |
||||
$process = proc_open( |
||||
$command, |
||||
[STDIN, $stdout, $stderr], |
||||
$pipes, |
||||
$cwd, |
||||
$environment, |
||||
['bypass_shell' => true], |
||||
); |
||||
if (!is_resource($process)) { |
||||
throw new RuntimeException('Failed to start: ' . implode(' ', $command)); |
||||
} |
||||
|
||||
$output = ''; |
||||
$error = ''; |
||||
if ($capture) { |
||||
$output = stream_get_contents($pipes[1]); |
||||
$error = stream_get_contents($pipes[2]); |
||||
fclose($pipes[1]); |
||||
fclose($pipes[2]); |
||||
} |
||||
$status = proc_close($process); |
||||
if ($status !== 0) { |
||||
throw new RuntimeException( |
||||
'Command failed (' . $status . '): ' . implode(' ', $command) . "\n" . $output . $error, |
||||
); |
||||
} |
||||
return $output; |
||||
} |
||||
|
||||
/** @return array{metrics: array<string, float>, checksums: array<string, string>} */ |
||||
function parseDynamicCallResults(string $output): array |
||||
{ |
||||
$metrics = []; |
||||
$checksums = []; |
||||
foreach (explode("\n", trim($output)) as $line) { |
||||
if (preg_match('/^([a-z_]+)_ns=([0-9.]+)$/', $line, $matches)) { |
||||
$metrics[$matches[1]] = (float) $matches[2]; |
||||
} elseif (preg_match('/^checksum_([a-z_]+)=(-?[0-9]+)$/', $line, $matches)) { |
||||
$checksums[$matches[1]] = $matches[2]; |
||||
} |
||||
} |
||||
return ['metrics' => $metrics, 'checksums' => $checksums]; |
||||
} |
||||
|
||||
$baselinePhp = getenv('PHP_BIN') ?: PHP_BINARY; |
||||
$compilerPhp = getenv('TPC_PHP_BIN') ?: $baselinePhp; |
||||
$runtimeProbe = static fn (string $binary): string => trim(runDynamicCallCommand([ |
||||
$binary, |
||||
'-n', |
||||
'-r', |
||||
'printf("%s;%d;%d;%d", PHP_VERSION, PHP_ZTS, PHP_DEBUG, PHP_INT_SIZE);', |
||||
], $root, true)); |
||||
$baselineRuntime = $runtimeProbe($baselinePhp); |
||||
$compilerRuntime = $runtimeProbe($compilerPhp); |
||||
if ($baselineRuntime !== $compilerRuntime) { |
||||
throw new RuntimeException( |
||||
"PHP_BIN and TPC_PHP_BIN must use the same PHP ABI for comparable results\n" |
||||
. "PHP_BIN={$baselineRuntime}\nTPC_PHP_BIN={$compilerRuntime}", |
||||
); |
||||
} |
||||
if (!$skipBuild) { |
||||
echo "Building TypePHP benchmark (-O3 + LTO)...\n"; |
||||
runDynamicCallCommand([ |
||||
$compilerPhp, |
||||
$root . '/bin/tpc.php', |
||||
$project, |
||||
'-j', |
||||
'8', |
||||
'--no-color', |
||||
'--no-progress', |
||||
], $root, false); |
||||
} |
||||
if (!is_file($binary)) { |
||||
throw new RuntimeException('Benchmark binary does not exist: ' . $binary); |
||||
} |
||||
|
||||
$environment = getenv(); |
||||
if ($selectedCase !== null && $selectedCase !== '') { |
||||
$environment['DYNAMIC_CALL_CASE'] = $selectedCase; |
||||
} |
||||
|
||||
$php = parseDynamicCallResults(runDynamicCallCommand([ |
||||
$baselinePhp, |
||||
'-n', |
||||
'-d', |
||||
'opcache.enable_cli=0', |
||||
'-d', |
||||
'opcache.jit=0', |
||||
'-r', |
||||
'require ' . var_export($source, true) . '; main();', |
||||
], $root, true, $environment)); |
||||
|
||||
if (PHP_OS_FAMILY !== 'Windows') { |
||||
$phpxHome = getenv('PHPX_HOME') ?: dirname($root) . '/phpx'; |
||||
$loaderVariable = PHP_OS_FAMILY === 'Darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH'; |
||||
$existing = $environment[$loaderVariable] ?? ''; |
||||
$phpHome = dirname(dirname(realpath($compilerPhp) ?: $compilerPhp)); |
||||
$environment[$loaderVariable] = $phpxHome . '/lib' . PATH_SEPARATOR . $phpHome . '/lib' |
||||
. ($existing === '' ? '' : PATH_SEPARATOR . $existing); |
||||
} |
||||
$typephp = parseDynamicCallResults(runDynamicCallCommand([$binary], $root, true, $environment)); |
||||
|
||||
$cases = [ |
||||
'direct', |
||||
'string_monomorphic', |
||||
'string_alternating', |
||||
'string_megamorphic', |
||||
'closure_monomorphic', |
||||
'closure_alternating', |
||||
'static_method_string', |
||||
'object_method_array', |
||||
'invokable_object', |
||||
'method_name_monomorphic', |
||||
'method_name_alternating', |
||||
'method_receiver_polymorphic', |
||||
]; |
||||
if ($selectedCase !== null && $selectedCase !== '') { |
||||
$cases = [$selectedCase]; |
||||
} |
||||
|
||||
echo "Runtime: {$baselineRuntime}\n"; |
||||
echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n"; |
||||
echo "--------------------------------------------------------------\n"; |
||||
foreach ($cases as $case) { |
||||
if (!isset($php['metrics'][$case], $typephp['metrics'][$case])) { |
||||
throw new RuntimeException("Missing benchmark metric: {$case}"); |
||||
} |
||||
if (($php['checksums'][$case] ?? null) !== ($typephp['checksums'][$case] ?? null)) { |
||||
throw new RuntimeException("Checksum mismatch for benchmark case: {$case}"); |
||||
} |
||||
printf( |
||||
"%-25s %10.2f %14.2f %12.2fx\n", |
||||
$case, |
||||
$php['metrics'][$case], |
||||
$typephp['metrics'][$case], |
||||
$typephp['metrics'][$case] / $php['metrics'][$case], |
||||
); |
||||
} |
||||
@ -0,0 +1,28 @@ |
||||
<?php |
||||
|
||||
function call_cache_target(int $value): int |
||||
{ |
||||
return $value + 1; |
||||
} |
||||
|
||||
function call_cache_sites(mixed $callback, object $object, mixed $method): array |
||||
{ |
||||
return [$callback(1), $object->$method(2)]; |
||||
} |
||||
|
||||
class CallCacheScopedTarget |
||||
{ |
||||
private function hidden(): int |
||||
{ |
||||
return 3; |
||||
} |
||||
|
||||
public function invoke(mixed $method): int |
||||
{ |
||||
return $this->$method(); |
||||
} |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
} |
||||
@ -0,0 +1,34 @@ |
||||
<?php |
||||
|
||||
use TypePhp\CompilerBase; |
||||
use TypePhp\CompilerTest; |
||||
|
||||
final class CallCacheCodegenTest extends BaseTest |
||||
{ |
||||
public function testDynamicCallSitesUseRequestLocalTypePhpCaches(): void |
||||
{ |
||||
global $translator; |
||||
|
||||
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); |
||||
$translator = $compiler; |
||||
$compiler->setBuildMode(CompilerBase::BUILD_MODE_EXT); |
||||
$compiler->setTargetName('call_cache_sites'); |
||||
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/call-cache-sites.php'; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
$generated = $compiler->convertFile($source); |
||||
$code = file_get_contents($generated); |
||||
$extension = file_get_contents($compiler->genExtension()); |
||||
|
||||
self::assertIsString($code); |
||||
self::assertIsString($extension); |
||||
self::assertSame(1, substr_count($code, 'typephp_call_cached(')); |
||||
self::assertSame(1, substr_count($code, 'typephp_call_method_cached(')); |
||||
self::assertStringContainsString('php::callScoped(', $code); |
||||
|
||||
self::assertStringContainsString('php::FunctionCallCacheSlot function_call_cache_map[1]', $extension); |
||||
self::assertStringContainsString('php::MethodCallCacheSlot method_call_cache_map[1]', $extension); |
||||
self::assertStringContainsString('typephp_get_function_call_cache(FunctionCallCacheId cache_id)', $extension); |
||||
self::assertStringContainsString('typephp_get_method_call_cache(MethodCallCacheId cache_id)', $extension); |
||||
} |
||||
} |
||||
@ -0,0 +1,85 @@ |
||||
--TEST-- |
||||
Dynamic call caches preserve polymorphic, object, and magic dispatch |
||||
--FILE-- |
||||
<?php |
||||
|
||||
function cached_first(int $value): string |
||||
{ |
||||
return 'first:' . $value; |
||||
} |
||||
|
||||
function cached_second(int $value): string |
||||
{ |
||||
return 'second:' . $value; |
||||
} |
||||
|
||||
class CachedMethodFirst |
||||
{ |
||||
public function run(int $value): string |
||||
{ |
||||
return 'method-first:' . $value; |
||||
} |
||||
} |
||||
|
||||
class CachedMethodSecond |
||||
{ |
||||
public function run(int $value): string |
||||
{ |
||||
return 'method-second:' . $value; |
||||
} |
||||
} |
||||
|
||||
class CachedMagicMethod |
||||
{ |
||||
public function __call(string $name, array $arguments): string |
||||
{ |
||||
return 'magic-' . $name . ':' . $arguments[0]; |
||||
} |
||||
} |
||||
|
||||
class CachedStaticMethod |
||||
{ |
||||
public static function run(int $value): string |
||||
{ |
||||
return 'static:' . $value; |
||||
} |
||||
} |
||||
|
||||
function invoke_function(mixed $callback, int $value): string |
||||
{ |
||||
return $callback($value); |
||||
} |
||||
|
||||
function invoke_method(object $object, mixed $method, int $value): string |
||||
{ |
||||
return $object->$method($value); |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$callbacks = ['cached_first', 'cached_second', 'cached_first']; |
||||
foreach ($callbacks as $index => $callback) { |
||||
var_dump(invoke_function($callback, $index)); |
||||
} |
||||
|
||||
$static = 'CachedStaticMethod::run'; |
||||
var_dump(invoke_function($static, 3)); |
||||
|
||||
$closure = static fn (int $value): string => 'closure:' . $value; |
||||
var_dump(invoke_function($closure, 4)); |
||||
|
||||
$objects = [new CachedMethodFirst(), new CachedMethodSecond(), new CachedMagicMethod()]; |
||||
foreach ($objects as $index => $object) { |
||||
var_dump(invoke_method($object, $index === 2 ? 'missing' : 'run', $index + 5)); |
||||
} |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
string(7) "first:0" |
||||
string(8) "second:1" |
||||
string(7) "first:2" |
||||
string(8) "static:3" |
||||
string(9) "closure:4" |
||||
string(14) "method-first:5" |
||||
string(15) "method-second:6" |
||||
string(15) "magic-missing:7" |
||||
@ -0,0 +1,30 @@ |
||||
--TEST-- |
||||
Native class: shutdown finalizers can use request-local dynamic call caches |
||||
--FILE-- |
||||
<?php |
||||
|
||||
function shutdown_dynamic_target(string $value): string |
||||
{ |
||||
return 'finalizer:' . $value; |
||||
} |
||||
|
||||
#[Native] |
||||
class ShutdownDynamicCaller |
||||
{ |
||||
public function __destruct() |
||||
{ |
||||
$callback = 'shutdown_dynamic_target'; |
||||
echo $callback('ok') . "\n"; |
||||
} |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
global $shutdownObject; |
||||
$shutdownObject = new ShutdownDynamicCaller(); |
||||
echo "main\n"; |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
main |
||||
finalizer:ok |
||||
Loading…
Reference in new issue