diff --git a/benchmark/README.md b/benchmark/README.md index ab4c62e0..27ffd30a 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -9,6 +9,8 @@ machine instead of committing absolute timing expectations. from `examples/`. - `bridge/` measures calls, property operations, and container operations that cross the generated-code/PHPX/Zend boundary. +- `dynamic-call/` measures monomorphic and polymorphic runtime callables so + call-site cache changes can be evaluated independently from direct AOT calls. - `property-access/` builds and compares dynamic/static property access under Zend PHP and TypePHP. diff --git a/benchmark/dynamic-call/.gitignore b/benchmark/dynamic-call/.gitignore new file mode 100644 index 00000000..8b27d794 --- /dev/null +++ b/benchmark/dynamic-call/.gitignore @@ -0,0 +1,3 @@ +/build/ +/dynamic_call +/*.rsp diff --git a/benchmark/dynamic-call/README.md b/benchmark/dynamic-call/README.md new file mode 100644 index 00000000..ddf27334 --- /dev/null +++ b/benchmark/dynamic-call/README.md @@ -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=` 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. diff --git a/benchmark/dynamic-call/benchmark.php b/benchmark/dynamic-call/benchmark.php new file mode 100644 index 00000000..4261b68c --- /dev/null +++ b/benchmark/dynamic-call/benchmark.php @@ -0,0 +1,284 @@ + $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); + } +} diff --git a/benchmark/dynamic-call/project.yml b/benchmark/dynamic-call/project.yml new file mode 100644 index 00000000..fb11dd1c --- /dev/null +++ b/benchmark/dynamic-call/project.yml @@ -0,0 +1,8 @@ +name: dynamic_call_benchmark +mode: bin +optimize: 3 +lto: true +build-dir: build +output: dynamic_call +sources: + - benchmark.php diff --git a/benchmark/dynamic-call/run.php b/benchmark/dynamic-call/run.php new file mode 100644 index 00000000..d98c6092 --- /dev/null +++ b/benchmark/dynamic-call/run.php @@ -0,0 +1,162 @@ + $command + * @param array|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, checksums: array} */ +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], + ); +} diff --git a/phpunit/code/call-cache-sites.php b/phpunit/code/call-cache-sites.php new file mode 100644 index 00000000..54abcf9e --- /dev/null +++ b/phpunit/code/call-cache-sites.php @@ -0,0 +1,28 @@ +$method(2)]; +} + +class CallCacheScopedTarget +{ + private function hidden(): int + { + return 3; + } + + public function invoke(mixed $method): int + { + return $this->$method(); + } +} + +function main(): void +{ +} diff --git a/phpunit/src/CallCacheCodegenTest.php b/phpunit/src/CallCacheCodegenTest.php new file mode 100644 index 00000000..de6aebfe --- /dev/null +++ b/phpunit/src/CallCacheCodegenTest.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index 9a1ff572..3f70a484 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -1287,9 +1287,9 @@ YAML); $this->assertStringNotContainsString('slot.reset()', $moduleInit, $mode); $this->assertMatchesRegularExpression( '/PHP_RSHUTDOWN_FUNCTION\([^)]*\)\s*\{\s*' + . 'php::request_shutdown\(\);\s*' . 'delete php_request_cache;\s*' - . 'php_request_cache = nullptr;\s*' - . 'php::request_shutdown\(\);/s', + . 'php_request_cache = nullptr;/s', $extension, $mode, ); diff --git a/phpunit/src/Python/PythonModuleTest.php b/phpunit/src/Python/PythonModuleTest.php index d114feb7..5650d192 100644 --- a/phpunit/src/Python/PythonModuleTest.php +++ b/phpunit/src/Python/PythonModuleTest.php @@ -36,7 +36,7 @@ final class PythonModuleTest extends TestCase $compiler->prepareFile($source); $cpp = file_get_contents($compiler->convertFile($source)); - self::assertStringContainsString('.call(', $cpp); + self::assertStringContainsString('typephp_call_method_cached(', $cpp); self::assertStringNotContainsString('php::python::callMember(', $cpp); } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index bdd58a52..a42b301d 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -340,6 +340,8 @@ class CompilerBase implements PropertyAccessContext * cache a runtime class together with a dynamic/hooked-property sentinel. */ protected int $propertyAccessCacheIndex = 0; + protected int $methodCallCacheIndex = 0; + protected int $functionCallCacheIndex = 0; /** @var array> Prepared declaration ASTs keyed by real path. */ protected array $preparedFileAsts = []; protected bool $traitDeclarationsComposed = false; @@ -1311,6 +1313,20 @@ class CompilerBase implements PropertyAccessContext return 'get_property_cache(PropertyCacheId{' . $id . '})'; } + protected function getMethodCallCache(): string + { + $this->assertCompilerPhase(self::PHASE_CONVERT, 'method call cache ID allocation'); + $id = $this->methodCallCacheIndex++; + return 'typephp_get_method_call_cache(MethodCallCacheId{' . $id . '})'; + } + + protected function getFunctionCallCache(): string + { + $this->assertCompilerPhase(self::PHASE_CONVERT, 'function call cache ID allocation'); + $id = $this->functionCallCacheIndex++; + return 'typephp_get_function_call_cache(FunctionCallCacheId{' . $id . '})'; + } + protected function getClassEntryPtr(string $className): string { $id = $this->getClassId($className); diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index 1b573516..8e1fdb1b 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -214,10 +214,17 @@ trait FunctionCallTrait $name = ''; } if (empty($expr->args)) { + if ($name === '' && $runtimeCallScope === null) { + return 'typephp_call_cached(' . $fn . ', ' . $this->getFunctionCallCache() . ')'; + } $scopeArg = $runtimeCallScope === null ? '' : $runtimeCallScope . ', '; return 'php::call(' . $scopeArg . $fn . ')'; } try { + if ($name === '' && $runtimeCallScope === null) { + return 'typephp_call_cached(' . $fn . ', ' . $this->getFunctionCallCache() . ', ' + . $this->parseCallArgs($expr->args) . ')'; + } return $this->genRuntimeFunctionCall( $fn, $expr->args, diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index ecccd0c7..a1fc3f58 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -857,10 +857,18 @@ trait MethodCallTrait if ($requiresDynamicScope && $this->methodDef) { return 'php::callScoped(' . $object . ', ' . $methodPtr . ', ' . $this->getCallableScopeExpr() . ')'; } + if (!$this->isNamedMethod($expr->name)) { + return 'typephp_call_method_cached(' . $object . ', ' . $methodPtr . ', ' + . $this->getMethodCallCache() . ')'; + } return $object . '.call(' . $methodPtr . ')'; } try { $class = empty($class) ? self::DYNAMIC_CALLED_CLASS : $class; + if (!$this->isNamedMethod($expr->name) && !($requiresDynamicScope && $this->methodDef)) { + return 'typephp_call_method_cached(' . $object . ', ' . $methodPtr . ', ' + . $this->getMethodCallCache() . ', ' . $this->parseCallArgs($expr->args) . ')'; + } return $this->genRuntimeObjectMethodCall( $object, $methodPtr, diff --git a/src/Translator.php b/src/Translator.php index b8d0feea..f6e70010 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -903,6 +903,8 @@ class Translator extends Preprocessor $lines[] = 'enum class PersistentFuncId : uint32_t {};'; $lines[] = 'enum class PersistentPropertyId : uint32_t {};'; $lines[] = 'enum class PropertyCacheId : uint32_t {};' . PHP_EOL; + $lines[] = 'enum class MethodCallCacheId : uint32_t {};' . PHP_EOL; + $lines[] = 'enum class FunctionCallCacheId : uint32_t {};' . PHP_EOL; $lines[] = 'zend_class_entry *get_class(RequestClassId class_id, const php::Str &class_name);'; $lines[] = 'zend_function *get_func(RequestFuncId func_id, const php::Str &func_name);'; @@ -912,6 +914,8 @@ class Translator extends Preprocessor $lines[] = 'zend_function *get_persistent_method(PersistentFuncId func_id, const php::Str &method_name, PersistentClassId class_id, const php::Str &class_name);'; $lines[] = 'uint32_t get_persistent_prop(PersistentPropertyId prop_id, const php::Str &prop_name, const php::Str &class_name);' . PHP_EOL; $lines[] = 'php::PropertyCacheSlot &get_property_cache(PropertyCacheId cache_id);' . PHP_EOL; + $lines[] = 'php::MethodCallCacheSlot &typephp_get_method_call_cache(MethodCallCacheId cache_id);' . PHP_EOL; + $lines[] = 'php::FunctionCallCacheSlot &typephp_get_function_call_cache(FunctionCallCacheId cache_id);' . PHP_EOL; foreach ($this->getClassLikesWithConstants() as $classDef) { foreach ($classDef->constants as $constant) { @@ -1060,6 +1064,10 @@ class Translator extends Preprocessor . max(1, count($this->funcMap)) . ']{};' . PHP_EOL; $code .= $this->getIndent() . 'php::PropertyCacheSlot property_cache_map[' . max(1, $this->propertyAccessCacheIndex) . ']{};' . PHP_EOL; + $code .= $this->getIndent() . 'php::MethodCallCacheSlot method_call_cache_map[' + . max(1, $this->methodCallCacheIndex) . ']{};' . PHP_EOL; + $code .= $this->getIndent() . 'php::FunctionCallCacheSlot function_call_cache_map[' + . max(1, $this->functionCallCacheIndex) . ']{};' . PHP_EOL; $code .= '};' . PHP_EOL; $code .= 'static THREAD_LOCAL php_request_cache_storage *php_request_cache = nullptr;' . PHP_EOL; @@ -1142,6 +1150,14 @@ uint32_t get_persistent_prop(PersistentPropertyId prop_id, const php::Str &prop_ php::PropertyCacheSlot &get_property_cache(PropertyCacheId cache_id) { return php_request_cache->property_cache_map[static_cast(cache_id)]; } + +php::MethodCallCacheSlot &typephp_get_method_call_cache(MethodCallCacheId cache_id) { + return php_request_cache->method_call_cache_map[static_cast(cache_id)]; +} + +php::FunctionCallCacheSlot &typephp_get_function_call_cache(FunctionCallCacheId cache_id) { + return php_request_cache->function_call_cache_map[static_cast(cache_id)]; +} CODE; $code .= "\n\n"; @@ -1512,9 +1528,9 @@ CODE; $code .= <<$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" diff --git a/tests/compiler/native-class/shutdown-finalizer-dynamic-call.phpt b/tests/compiler/native-class/shutdown-finalizer-dynamic-call.phpt new file mode 100644 index 00000000..e7fcc8d8 --- /dev/null +++ b/tests/compiler/native-class/shutdown-finalizer-dynamic-call.phpt @@ -0,0 +1,30 @@ +--TEST-- +Native class: shutdown finalizers can use request-local dynamic call caches +--FILE-- + +--EXPECT-- +main +finalizer:ok