diff --git a/benchmark/README.md b/benchmark/README.md index a0a14e32..ab4c62e0 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -7,6 +7,8 @@ machine instead of committing absolute timing expectations. - `bench.php` and `micro_bench.php` are the original general workloads moved from `examples/`. +- `bridge/` measures calls, property operations, and container operations that + cross the generated-code/PHPX/Zend boundary. - `property-access/` builds and compares dynamic/static property access under Zend PHP and TypePHP. diff --git a/benchmark/bridge/.gitignore b/benchmark/bridge/.gitignore new file mode 100644 index 00000000..4148dfb2 --- /dev/null +++ b/benchmark/bridge/.gitignore @@ -0,0 +1,4 @@ +/build/ +/bridge_benchmark +/bridge_benchmark.exe +/*.rsp diff --git a/benchmark/bridge/README.md b/benchmark/bridge/README.md new file mode 100644 index 00000000..22f4511e --- /dev/null +++ b/benchmark/bridge/README.md @@ -0,0 +1,19 @@ +# PHP bridge benchmark + +This benchmark measures the cost of common operations that cross between +generated TypePHP code and PHPX/Zend. It is the maintained version of the +original `debug/bridge-bench` reproducer. + +Run it from the repository root: + +```bash +PHPX_HOME=../phpx PHP_BIN=/opt/php-8.5-nts/bin/php php benchmark/bridge/run.php +``` + +The TypePHP binary is built with `-O3` and LTO. `PHP_BIN` selects the Zend PHP +binary used for the baseline; `TPC_PHP_BIN` can independently select the PHP +binary that runs `bin/tpc.php`. Add `--skip-build` to reuse an existing binary. +Use `--case=magic_property` to run only one workload while profiling. + +Results are the best of five rounds after warm-up. Always compare PHP and +TypePHP in the same run on an otherwise idle machine. diff --git a/benchmark/bridge/benchmark.php b/benchmark/bridge/benchmark.php new file mode 100644 index 00000000..591fab19 --- /dev/null +++ b/benchmark/bridge/benchmark.php @@ -0,0 +1,183 @@ +hit($i); + } + return $sum; +} + +final class BridgeCounter +{ + public int $value = 0; +} + +function bridgePropertyAccess(int $iterations): int +{ + $counter = new BridgeCounter(); + for ($i = 0; $i < $iterations; $i++) { + $counter->value++; + } + return $counter->value; +} + +final class BridgeMagicCall +{ + public function __call(string $name, array $arguments): int + { + return $arguments[0] + 1; + } +} + +function bridgeMagicCall(int $iterations): int +{ + $object = new BridgeMagicCall(); + $sum = 0; + for ($i = 0; $i < $iterations; $i++) { + $sum += $object->hit($i); + } + return $sum; +} + +final class BridgeMagicProperty +{ + private array $data = ['value' => 0]; + + public function __get(string $name): int + { + return $this->data[$name]; + } + + public function __set(string $name, mixed $value): void + { + $this->data[$name] = $value; + } +} + +function bridgeMagicProperty(int $iterations): int +{ + $object = new BridgeMagicProperty(); + for ($i = 0; $i < $iterations; $i++) { + $object->value = $object->value + 1; + } + return $object->value; +} + +function bridgeArrayAppend(int $iterations): int +{ + $values = []; + for ($i = 0; $i < $iterations; $i++) { + $values[] = $i; + } + return count($values); +} + +function bridgeStringConcat(int $iterations): string +{ + $value = ''; + for ($i = 0; $i < $iterations; $i++) { + $value .= 'x'; + } + return $value; +} + +function measureBridgeCase(string $case): array +{ + $iterations = match ($case) { + 'array_append', 'string_concat' => BRIDGE_CONTAINER_ITERATIONS, + default => BRIDGE_ITERATIONS, + }; + $best = 0; + $bestResult = null; + for ($round = 0; $round < BRIDGE_ROUNDS; $round++) { + $start = hrtime(true); + $result = match ($case) { + 'pure_int' => bridgePureInt($iterations), + 'function_call' => bridgeFunctionCall($iterations), + 'method_call' => bridgeMethodCall($iterations), + 'property_access' => bridgePropertyAccess($iterations), + 'magic_call' => bridgeMagicCall($iterations), + 'magic_property' => bridgeMagicProperty($iterations), + 'array_append' => bridgeArrayAppend($iterations), + 'string_concat' => bridgeStringConcat($iterations), + default => throw new RuntimeException("Unknown benchmark case: {$case}"), + }; + $elapsed = hrtime(true) - $start; + if ($round === 0 || $elapsed < $best) { + $best = $elapsed; + $bestResult = $result; + } + } + return [$best / $iterations, $bestResult]; +} + +function main(): void +{ + bridgePureInt(1000); + bridgeFunctionCall(1000); + bridgeMethodCall(1000); + bridgePropertyAccess(1000); + bridgeMagicCall(1000); + bridgeMagicProperty(1000); + bridgeArrayAppend(1000); + bridgeStringConcat(1000); + + $selectedCase = getenv('BRIDGE_CASE'); + foreach ([ + 'pure_int', + 'function_call', + 'method_call', + 'property_access', + 'magic_call', + 'magic_property', + 'array_append', + 'string_concat', + ] as $case) { + if (is_string($selectedCase) && $selectedCase !== '' && $case !== $selectedCase) { + continue; + } + [$nanoseconds, $result] = measureBridgeCase($case); + printf("%s_ns=%.3f\n", $case, $nanoseconds); + printf("checksum_%s=%s\n", $case, is_string($result) ? strlen($result) : $result); + } +} diff --git a/benchmark/bridge/project.yml b/benchmark/bridge/project.yml new file mode 100644 index 00000000..f1f8d8d7 --- /dev/null +++ b/benchmark/bridge/project.yml @@ -0,0 +1,8 @@ +name: bridge_benchmark +mode: bin +optimize: 3 +lto: true +build-dir: build +output: bridge_benchmark +sources: + - benchmark.php diff --git a/benchmark/bridge/run.php b/benchmark/bridge/run.php new file mode 100644 index 00000000..fd3bfd69 --- /dev/null +++ b/benchmark/bridge/run.php @@ -0,0 +1,126 @@ + $command */ +function runBridgeCommand(array $command, string $cwd, ?array $environment = null): string +{ + $process = proc_open( + $command, + [STDIN, ['pipe', 'w'], ['pipe', 'w']], + $pipes, + $cwd, + $environment, + ['bypass_shell' => true], + ); + if (!is_resource($process)) { + throw new RuntimeException('Failed to start: ' . implode(' ', $command)); + } + $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 */ +function parseBridgeResults(string $output): array +{ + $results = []; + foreach (explode("\n", trim($output)) as $line) { + if (!preg_match('/^([a-z_]+)_ns=([0-9.]+)$/', $line, $matches)) { + continue; + } + $results[$matches[1]] = (float) $matches[2]; + } + return $results; +} + +$compilerPhp = getenv('TPC_PHP_BIN') ?: PHP_BINARY; +$baselinePhp = getenv('PHP_BIN') ?: PHP_BINARY; +if (!$skipBuild) { + echo "Building TypePHP benchmark (-O3 + LTO)...\n"; + echo runBridgeCommand([ + $compilerPhp, + $root . '/bin/tpc.php', + $project, + '-j', + '8', + '--no-color', + '--no-progress', + ], $root); +} +if (!is_file($binary)) { + throw new RuntimeException('Benchmark binary does not exist: ' . $binary); +} + +$benchmarkEnvironment = getenv(); +if ($selectedCase !== null && $selectedCase !== '') { + $benchmarkEnvironment['BRIDGE_CASE'] = $selectedCase; +} +$php = parseBridgeResults(runBridgeCommand([ + $baselinePhp, + '-n', + '-d', + 'opcache.enable_cli=0', + '-d', + 'opcache.jit=0', + '-r', + 'require ' . var_export($source, true) . '; main();', +], $root, $benchmarkEnvironment)); + +$environment = $benchmarkEnvironment; +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] ?? ''; + $environment[$loaderVariable] = $phpxHome . '/lib' + . ($existing === '' ? '' : PATH_SEPARATOR . $existing); +} +$typephp = parseBridgeResults(runBridgeCommand([$binary], $root, $environment)); + +echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n"; +echo "------------------------------------------------------------\n"; +$metrics = [ + 'pure_int', + 'function_call', + 'method_call', + 'property_access', + 'magic_call', + 'magic_property', + 'array_append', + 'string_concat', +]; +if ($selectedCase !== null && $selectedCase !== '') { + $metrics = [$selectedCase]; +} +foreach ($metrics as $metric) { + if (!isset($php[$metric], $typephp[$metric])) { + throw new RuntimeException("Missing benchmark metric: {$metric}"); + } + printf( + "%-22s %10.2f %14.2f %12.2fx\n", + $metric, + $php[$metric], + $typephp[$metric], + $typephp[$metric] / $php[$metric], + ); +} diff --git a/phpunit/code/property-cache-sites.php b/phpunit/code/property-cache-sites.php new file mode 100644 index 00000000..05f2bc52 --- /dev/null +++ b/phpunit/code/property-cache-sites.php @@ -0,0 +1,40 @@ +named; + $object->named = $value; + propertyCacheReceiver($object)->other = $value; + + $dynamic = $object->{$name}; + $object->{$name} = $value; + return [$first, $dynamic]; +} + +final class DirectMagicPropertySites +{ + 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 directMagicPropertySites(mixed $value): mixed +{ + $object = new DirectMagicPropertySites(); + $current = $object->missing; + $object->missing = $value; + return $current; +} diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index f6629b2c..f57d0824 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -1276,6 +1276,19 @@ YAML); $this->assertStringContainsString('zend_class_entry *get_class(', $extension, $mode); $this->assertStringContainsString('static void module_init()', $extension, $mode); $this->assertStringContainsString('static void module_clean()', $extension, $mode); + $moduleInitStart = strpos($extension, 'static void module_init()'); + $moduleCleanStart = strpos($extension, 'static void module_clean()'); + $this->assertIsInt($moduleInitStart, $mode); + $this->assertIsInt($moduleCleanStart, $mode); + $moduleInit = substr($extension, $moduleInitStart, $moduleCleanStart - $moduleInitStart); + $this->assertStringNotContainsString('slot.reset()', $moduleInit, $mode); + $this->assertMatchesRegularExpression( + '/PHP_RSHUTDOWN_FUNCTION\([^)]*\)\s*\{\s*' + . 'for \(auto &slot : php_property_cache_map\) \{\s*slot\.reset\(\);\s*\}\s*' + . 'php::request_shutdown\(\);/s', + $extension, + $mode, + ); $this->assertStringContainsString('typephp_register_fiber_generator_class();', $extension, $mode); $this->assertStringContainsString('typephp_unregister_fiber_generator_class();', $extension, $mode); $this->assertStringNotContainsString('php_app_init', $extension, $mode); diff --git a/phpunit/src/PropertyCacheCodegenTest.php b/phpunit/src/PropertyCacheCodegenTest.php new file mode 100644 index 00000000..4ce38896 --- /dev/null +++ b/phpunit/src/PropertyCacheCodegenTest.php @@ -0,0 +1,27 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + self::assertSame(1, substr_count($code, 'typephp_read_property_cached(')); + self::assertSame(2, substr_count($code, 'typephp_write_property_cached(')); + self::assertStringContainsString('.attr(name, php::AttrMode::Get)', $code); + self::assertStringContainsString('typephp_write_property_scoped(object, name, value', $code); + self::assertSame(1, substr_count($code, 'typephp_read_magic_property_direct(')); + self::assertSame(1, substr_count($code, 'typephp_write_magic_property_direct(')); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index fde10bed..1b069b74 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -331,6 +331,12 @@ class CompilerBase implements PropertyAccessContext */ protected array $persistentPropMap = []; protected int $persistentPropIndex = 0; + /** + * Per-generated-access-site Zend object-handler cache slots. Unlike the + * declared-property offset cache above, these are request-local and may + * cache a runtime class together with a dynamic/hooked-property sentinel. + */ + protected int $propertyAccessCacheIndex = 0; /** @var array> Prepared declaration ASTs keyed by real path. */ protected array $preparedFileAsts = []; protected bool $declarationExpressionsFinalized = false; @@ -1289,6 +1295,13 @@ class CompilerBase implements PropertyAccessContext return $id; } + protected function getPropertyAccessCache(): string + { + $this->assertCompilerPhase(self::PHASE_CONVERT, 'property access cache ID allocation'); + $id = $this->propertyAccessCacheIndex++; + return 'get_property_cache(PropertyCacheId{' . $id . '})'; + } + protected function getClassEntryPtr(string $className): string { $id = $this->getClassId($className); diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index c553fd09..84f1db88 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -23,41 +23,142 @@ use TypePhp\Generator\Symbol; trait PropertyAccessTrait { + /** + * Resolve a direct TypePHP magic-property body only for an exact, simple + * compiled class. The runtime helper still rechecks handlers, lazy state, + * declared/dynamic properties, and Zend's recursion guard before calling + * this function; otherwise it falls back to the standard handler. + * + * @return array{function: string, classEntry: string}|null + */ + private function resolveDirectMagicPropertyAccess( + Expr\PropertyFetch $expr, + string $object, + string $magicMethod, + ): ?array { + if (!$this->isIdExpr($expr->name) + || !$this->isVarExpr($expr->var) + || $this->getVarType($object) !== Type::OBJECT + ) { + return null; + } + + $class = $this->detectClassOfExpr($expr->var); + if ($class === '' || !$this->hasClass($class)) { + return null; + } + + $exactClass = null; + if ($object === 'this_' && $this->isCurrentClassFinal()) { + $exactClass = $this->getFullClassName(); + } elseif (isset($this->context->exactObjects[$object])) { + $exactClass = $this->context->exactObjects[$object]; + } elseif ($this->isFinalClass($class)) { + $exactClass = $class; + } + if ($exactClass === null + || strcasecmp(ltrim($exactClass, '\\'), ltrim($class, '\\')) !== 0 + || !$this->hasClass($exactClass) + ) { + return null; + } + + $classDef = $this->getClass($exactClass); + $property = $this->parseIdentifier($expr->name); + // Keep the first implementation deliberately narrow. An internal or + // compiled parent may install custom object handlers; inherited magic + // methods also need a different generated receiver ABI. + if ($classDef->extends !== '' + || $classDef->nativeObject + || $classDef->trait + || $classDef->hasProperty($property) + || !$classDef->hasMethod($magicMethod) + ) { + return null; + } + + $method = $classDef->getMethod($magicMethod); + if ($method->functionDef === null + || ($magicMethod === '__get' && $method->functionDef->returnsByRef) + ) { + return null; + } + $nativeFunction = $this->getNativeName( + $magicMethod, + $classDef->namespace, + $classDef->name, + ); + if (!$this->hasFunction($nativeFunction)) { + return null; + } + + return [ + 'function' => self::PREFIX . $nativeFunction, + 'classEntry' => $this->getLocalClassEntryPtr($exactClass), + ]; + } + protected function usesTraitPropertyScope(string $object): bool { return $this->classDef?->trait && $object === 'this_'; } - protected function emitDynamicPropertyRead(string $object, string $property): string + protected function emitDynamicPropertyRead(string $object, string $property, ?string $cache = null): string { if ($this->usesTraitPropertyScope($object)) { return 'typephp_read_property_scoped(' . $object . ', ' . $property . ', php::FakeScopeGuard::current(), php::AttrMode::Get)'; } + if ($cache !== null) { + return 'typephp_read_property_cached(' + . $object . ', ' . $property . ', php::AttrMode::Get, ' . $cache . ')'; + } return "{$object}.getProperty({$property})"; } - protected function emitDynamicPropertyWrite(string $object, string $property, string $value): string + protected function emitDynamicPropertyWrite( + string $object, + string $property, + string $value, + ?string $cache = null, + ): string { $scope = $this->usesTraitPropertyScope($object) ? 'php::FakeScopeGuard::current()' : ($this->class ? $this->getLocalClassEntryPtr($this->getFullClassName()) : 'nullptr'); + if ($cache !== null && !$this->usesTraitPropertyScope($object)) { + return 'typephp_write_property_cached(' + . $object . ', ' . $property . ', ' . $value . ', ' . $scope . ', ' . $cache . ')'; + } return 'typephp_write_property_scoped(' . $object . ', ' . $property . ', ' . $value . ', ' . $scope . ')'; } - protected function emitDynamicPropertyTargetRead(PropertyWriteTarget $target): string + protected function emitDynamicPropertyTargetRead(PropertyWriteTarget $target, ?string $cache = null): string { $this->assertDynamicPropertyTarget($target); - return $this->emitDynamicPropertyRead($target->getDynamicObjectExpr(), $target->getDynamicPropertyExpr()); + return $this->emitDynamicPropertyRead( + $target->getDynamicObjectExpr(), + $target->getDynamicPropertyExpr(), + $cache, + ); } - protected function emitDynamicPropertyTargetWrite(PropertyWriteTarget $target, string $value): string + protected function emitDynamicPropertyTargetWrite( + PropertyWriteTarget $target, + string $value, + ?string $cache = null, + ): string { $this->assertDynamicPropertyTarget($target); - return $this->emitDynamicPropertyWrite($target->getDynamicObjectExpr(), $target->getDynamicPropertyExpr(), $value); + return $this->emitDynamicPropertyWrite( + $target->getDynamicObjectExpr(), + $target->getDynamicPropertyExpr(), + $value, + $cache, + ); } protected function emitDynamicPropertyTargetUnset(PropertyWriteTarget $target): string @@ -74,18 +175,28 @@ trait PropertyAccessTrait return $target->getDynamicObjectExpr() . '.attrRef(' . $target->getDynamicPropertyExpr() . ')'; } - protected function emitDynamicPropertyTargetAppendArray(PropertyWriteTarget $target, string $value): string + protected function emitDynamicPropertyTargetAppendArray( + PropertyWriteTarget $target, + string $value, + ?string $cache = null, + ): string { $this->assertDynamicPropertyTarget($target); return $this->emitDynamicPropertyAppendArray( $target->getDynamicObjectExpr(), $target->getDynamicPropertyExpr(), - $value + $value, + $cache, ); } - protected function emitDynamicPropertyTargetUpdateArray(PropertyWriteTarget $target, string $dim, string $value): string + protected function emitDynamicPropertyTargetUpdateArray( + PropertyWriteTarget $target, + string $dim, + string $value, + ?string $cache = null, + ): string { $this->assertDynamicPropertyTarget($target); @@ -93,7 +204,8 @@ trait PropertyAccessTrait $target->getDynamicObjectExpr(), $target->getDynamicPropertyExpr(), $dim, - $value + $value, + $cache, ); } @@ -104,26 +216,50 @@ trait PropertyAccessTrait protected function emitDynamicPropertyFetchRead(Expr\PropertyFetch $expr, ?PropertyWriteTarget $target = null): string { + $cache = $this->isIdExpr($expr->name) && !$this->isNativePropertyAccess($expr) + ? $this->getPropertyAccessCache() + : null; if ($this->canEmitDynamicPropertyTarget($target)) { - return $this->emitDynamicPropertyTargetRead($target); + return $this->emitDynamicPropertyTargetRead($target, $cache); } return $this->emitDynamicPropertyRead( $this->parseIdentifier($expr->var), - $this->propertyNameToStr($expr->name, literal: true) + $this->propertyNameToStr($expr->name, literal: true), + $cache, ); } protected function emitDynamicPropertyFetchWrite(Expr\PropertyFetch $expr, string $value, ?PropertyWriteTarget $target = null): string { + $cache = $this->isIdExpr($expr->name) && !$this->isNativePropertyAccess($expr) + ? $this->getPropertyAccessCache() + : null; + $object = $this->canEmitDynamicPropertyTarget($target) + ? $target->getDynamicObjectExpr() + : $this->parseIdentifier($expr->var); + $direct = $cache !== null && $this->hasVar($value) && $this->getVarType($value) === Type::VAR + ? $this->resolveDirectMagicPropertyAccess($expr, $object, '__set') + : null; + if ($direct !== null) { + $property = $this->propertyNameToStr($expr->name, literal: true); + $scope = $this->class + ? $this->getLocalClassEntryPtr($this->getFullClassName()) + : 'nullptr'; + return 'typephp_write_magic_property_direct(' + . $object . ', ' . $property . ', ' . $value . ', ' . $scope . ', ' + . $direct['classEntry'] . ', ' . $cache . ', [&]() {' + . $direct['function'] . '(' . $object . ', ' . $property . ', ' . $value . '); })'; + } if ($this->canEmitDynamicPropertyTarget($target)) { - return $this->emitDynamicPropertyTargetWrite($target, $value); + return $this->emitDynamicPropertyTargetWrite($target, $value, $cache); } return $this->emitDynamicPropertyWrite( - $this->parseIdentifier($expr->var), + $object, $this->propertyNameToStr($expr->name, literal: true), - $value + $value, + $cache, ); } @@ -151,13 +287,18 @@ trait PropertyAccessTrait return $this->parseWritableIdentifier($expr) . ".newItem() = {$value}"; } if ($this->canEmitDynamicPropertyTarget($target)) { - return $this->emitDynamicPropertyTargetAppendArray($target, $value); + return $this->emitDynamicPropertyTargetAppendArray( + $target, + $value, + $this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null, + ); } return $this->emitDynamicPropertyAppendArray( $this->parseIdentifier($expr->var), $this->propertyNameToStr($expr->name, literal: true), - $value + $value, + $this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null, ); } @@ -167,34 +308,61 @@ trait PropertyAccessTrait return $this->parseWritableIdentifier($expr) . ".item({$dim}, true) = {$value}"; } if ($this->canEmitDynamicPropertyTarget($target)) { - return $this->emitDynamicPropertyTargetUpdateArray($target, $dim, $value); + return $this->emitDynamicPropertyTargetUpdateArray( + $target, + $dim, + $value, + $this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null, + ); } return $this->emitDynamicPropertyUpdateArray( $this->parseIdentifier($expr->var), $this->propertyNameToStr($expr->name, literal: true), $dim, - $value + $value, + $this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null, ); } - protected function emitDynamicPropertyAppendArray(string $object, string $property, string $value): string + protected function emitDynamicPropertyAppendArray( + string $object, + string $property, + string $value, + ?string $cache = null, + ): string { if ($this->usesTraitPropertyScope($object)) { return 'typephp_read_property_scoped(' . $object . ', ' . $property . ', php::FakeScopeGuard::current(), php::AttrMode::Update)' . ".newItem() = {$value}"; } + if ($cache !== null) { + return 'typephp_read_property_cached(' + . $object . ', ' . $property . ', php::AttrMode::Update, ' . $cache . ')' + . ".newItem() = {$value}"; + } return "{$object}.attr({$property}, php::AttrMode::Update).newItem() = {$value}"; } - protected function emitDynamicPropertyUpdateArray(string $object, string $property, string $dim, string $value): string + protected function emitDynamicPropertyUpdateArray( + string $object, + string $property, + string $dim, + string $value, + ?string $cache = null, + ): string { if ($this->usesTraitPropertyScope($object)) { return 'typephp_read_property_scoped(' . $object . ', ' . $property . ', php::FakeScopeGuard::current(), php::AttrMode::Update)' . ".item({$dim}, true) = {$value}"; } + if ($cache !== null) { + return 'typephp_read_property_cached(' + . $object . ', ' . $property . ', php::AttrMode::Update, ' . $cache . ')' + . ".item({$dim}, true) = {$value}"; + } return "{$object}.attr({$property}, php::AttrMode::Update).item({$dim}, true) = {$value}"; } @@ -1003,9 +1171,21 @@ trait PropertyAccessTrait . $this->getNativeObjectPropertyCppName($resolution->propertyDef, $resolution->classDef); } $objectVar = $objectName; - if ($this->usesTraitPropertyScope($objectVar)) { + $directMagic = !$update && !$this->isNativePropertyAccess($expr) + ? $this->resolveDirectMagicPropertyAccess($expr, $objectVar, '__get') + : null; + if ($directMagic !== null) { + $getProperty = 'typephp_read_magic_property_direct(' + . $objectVar . ', ' . $id . ', ' . $directMagic['classEntry'] . ', ' + . $this->getPropertyAccessCache() . ', [&]() {' + . ' return ' . $directMagic['function'] . '(' . $objectVar . ', ' . $id . '); })'; + } elseif ($this->usesTraitPropertyScope($objectVar)) { $getProperty = 'typephp_read_property_scoped(' . $objectVar . ', ' . $id . ', php::FakeScopeGuard::current(), ' . $this->escapeAttrMode($update) . ')'; + } elseif ($this->isIdExpr($property) && !$this->isNativePropertyAccess($expr)) { + $getProperty = 'typephp_read_property_cached(' + . $objectVar . ', ' . $id . ', ' . $this->escapeAttrMode($update) . ', ' + . $this->getPropertyAccessCache() . ')'; } else { $getProperty = $objectVar . '.attr(' . $id . ', ' . $this->escapeAttrMode($update) . ')'; } diff --git a/src/Translator.php b/src/Translator.php index f9dba8b0..8e1a482b 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -819,7 +819,8 @@ class Translator extends Preprocessor $lines[] = 'enum class PersistentClassId : uint32_t {};'; $lines[] = 'enum class RequestFuncId : uint32_t {};'; $lines[] = 'enum class PersistentFuncId : uint32_t {};'; - $lines[] = 'enum class PersistentPropertyId : uint32_t {};' . PHP_EOL; + $lines[] = 'enum class PersistentPropertyId : uint32_t {};'; + $lines[] = 'enum class PropertyCacheId : 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);'; @@ -828,6 +829,7 @@ class Translator extends Preprocessor $lines[] = 'zend_function *get_persistent_func(PersistentFuncId func_id, const php::Str &func_name);'; $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; foreach ($this->getClassLikesWithConstants() as $classDef) { foreach ($classDef->constants as $constant) { @@ -927,6 +929,11 @@ class Translator extends Preprocessor // No dynamic propMap: the property offset cache only covers declared // properties of compiled/built-in classes (see getPropertyId). $code .= 'static php::PersistentCacheSlot ' . self::PREFIX . self::PERSISTENT_PROP_MAP . '[' . max(1, count($this->persistentPropMap)) . ']{};' . PHP_EOL; + // Zend's object handlers use three adjacent pointers as one cache + // entry. Keep these slots request-local: a named access may receive a + // class provided by an ordinary PHP script whose CE is not persistent. + $code .= 'static THREAD_LOCAL php::PropertyCacheSlot ' . self::PREFIX . 'property_cache_map[' + . max(1, $this->propertyAccessCacheIndex) . ']{};' . PHP_EOL; $code .= "// functions \n"; @@ -985,6 +992,10 @@ uint32_t get_persistent_prop(PersistentPropertyId prop_id, const php::Str &prop_ }); return value - 1024; } + +php::PropertyCacheSlot &get_property_cache(PropertyCacheId cache_id) { + return php_property_cache_map[static_cast(cache_id)]; +} CODE; $code .= "\n\n"; @@ -1300,6 +1311,9 @@ CODE; $code .= <<values[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + echo "first:set:$name=$value\n"; + $this->values[$name] = $value; + } +} + +class SecondMagicProperty +{ + private array $values = []; + + public function __get(string $name): mixed + { + echo "second:get:$name\n"; + return $this->values[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + echo "second:set:$name=$value\n"; + $this->values[$name] = $value; + } +} + +#[AllowDynamicProperties] +class MaterializingMagicProperty +{ + public int $setCalls = 0; + + public function __set(string $name, mixed $value): void + { + $this->setCalls++; + $this->{$name} = $value; + } +} + +final class RecursiveMagicProperty +{ + public int $getCalls = 0; + + public function __get(string $name): mixed + { + $this->getCalls++; + return @$this->{$name}; + } +} + +final class ThrowingMagicProperty +{ + public int $getCalls = 0; + public int $setCalls = 0; + + public function __get(string $name): mixed + { + $this->getCalls++; + if ($this->getCalls === 1) { + throw new RuntimeException('get failed'); + } + return 77; + } + + public function __set(string $name, mixed $value): void + { + $this->setCalls++; + if ($this->setCalls === 1) { + throw new RuntimeException('set failed'); + } + } +} + +function readNamedProperty(object $object): mixed +{ + return $object->value; +} + +function writeNamedProperty(object $object, mixed $value): void +{ + $object->value = $value; +} + +function readDynamicProperty(object $object, string $name): mixed +{ + return $object->{$name}; +} + +function writeDynamicProperty(object $object, string $name, mixed $value): void +{ + $object->{$name} = $value; +} + +function namedPropertyReceiver(object $object, int &$calls): object +{ + $calls++; + return $object; +} + +function main(): void +{ + $first = new FirstMagicProperty(); + $second = new SecondMagicProperty(); + + // One generated access site sees different runtime class entries. Zend + // must invalidate and refill the polymorphic cache rather than reusing the + // first class's magic-property result. + writeNamedProperty($first, 10); + writeNamedProperty($second, 20); + writeNamedProperty($first, 30); + var_dump(readNamedProperty($first)); + var_dump(readNamedProperty($second)); + var_dump(readNamedProperty($first)); + + // A dynamic property-name expression deliberately remains uncached. + writeDynamicProperty($second, 'other', 40); + var_dump(readDynamicProperty($second, 'other')); + + // __set() materializes a real dynamic property on its first invocation. + // The cached dynamic-property sentinel must continue to dispatch through + // Zend so the second write reaches the newly created property directly. + $materialized = new MaterializingMagicProperty(); + writeNamedProperty($materialized, 1); + writeNamedProperty($materialized, 2); + var_dump($materialized->setCalls, readNamedProperty($materialized)); + + // The direct TypePHP path owns the same per-name Zend recursion guard. + // Re-reading the same missing property from __get() must not recurse. + $recursive = new RecursiveMagicProperty(); + var_dump(readNamedProperty($recursive), $recursive->getCalls); + + // A direct magic method may throw a C++ exception. Its RAII guard must be + // released before the next access, just as Zend clears its guard after a + // VM-level exception. + $throwing = new ThrowingMagicProperty(); + try { + writeNamedProperty($throwing, 1); + } catch (RuntimeException $e) { + echo "set caught\n"; + } + writeNamedProperty($throwing, 2); + var_dump($throwing->setCalls); + try { + readNamedProperty($throwing); + } catch (RuntimeException $e) { + echo "get caught\n"; + } + var_dump(readNamedProperty($throwing), $throwing->getCalls); + + // A parenthesized receiver is cacheable, but is still evaluated once. + $calls = 0; + namedPropertyReceiver($first, $calls)->value = 50; + var_dump($calls, namedPropertyReceiver($first, $calls)->value, $calls); +} +?> +--EXPECT-- +first:set:value=10 +second:set:value=20 +first:set:value=30 +first:get:value +int(30) +second:get:value +int(20) +first:get:value +int(30) +second:set:other=40 +second:get:other +int(40) +int(1) +int(2) +NULL +int(1) +set caught +int(2) +get caught +int(77) +int(2) +first:set:value=50 +first:get:value +int(1) +int(50) +int(2)