diff --git a/benchmark/property-method-call/README.md b/benchmark/property-method-call/README.md new file mode 100644 index 00000000..7d8d6d72 --- /dev/null +++ b/benchmark/property-method-call/README.md @@ -0,0 +1,24 @@ +# Typed property method calls + +This benchmark calls a final class method through a declared object property +1,000,000 times, returning checksum `7000000`. It isolates receiver/property and +method-call overhead; it is not a whole-application performance estimate. + +Build from the repository root with a matching PHPX runtime: + +```sh +php bin/tpc.php benchmark/property-method-call/project.yml --no-progress \ + --build-dir /tmp/typephp-property-method-build -o /tmp/property-method-call +/tmp/property-method-call +``` + +For a before/after comparison, use the same PHPX, compiler flags and workload in +both checkouts, separate build directories, and alternate the two binaries after +a warmup. Verify equal output before comparing elapsed time. + +An initial Linux ARM64/PHP 8.5.10 ZTS/PHPX `4b3a472` O2 comparison against TypePHP +`a1782233` measured five-run median process times of 29.97 ms before and 22.63 ms +after (24.5% lower elapsed time). Timings include process startup. The normal +22-command dungeon scenario improved only about 2.4%, and its exception-heavy +28-command scenario showed no reliable improvement. These results demonstrate a +local call-path improvement, not a claim that all applications become 24.5% faster. diff --git a/benchmark/property-method-call/main.php b/benchmark/property-method-call/main.php new file mode 100644 index 00000000..8bb20b5c --- /dev/null +++ b/benchmark/property-method-call/main.php @@ -0,0 +1,22 @@ +counter = new PropertyCounter(); } + public function run(int $count): int + { + $sum = 0; + for ($i = 0; $i < $count; ++$i) { + $sum += $this->counter->value(); + } + return $sum; + } +} +function main(int $argc, array $argv): void +{ + echo (new PropertyCounterHolder())->run(1000000), "\n"; +} diff --git a/benchmark/property-method-call/project.yml b/benchmark/property-method-call/project.yml new file mode 100644 index 00000000..8c87bcdc --- /dev/null +++ b/benchmark/property-method-call/project.yml @@ -0,0 +1,5 @@ +name: property_method_call +build-mode: bin +optimize: 2 +sources: + - main.php diff --git a/phpunit/code/typed-property-method-call.php b/phpunit/code/typed-property-method-call.php new file mode 100644 index 00000000..242cb8b5 --- /dev/null +++ b/phpunit/code/typed-property-method-call.php @@ -0,0 +1,248 @@ +id + $value; + return $this->id; + } +} + +class PropertyCallBase +{ + public function value(): int { return 1; } +} + +final class PropertyCallChild extends PropertyCallBase +{ + public function value(): int { return 2; } +} + +final class PropertyCallHolder +{ + public PropertyCallTarget $target; + public PropertyCallBase $polymorphic; + public ?PropertyCallTarget $nullable = null; + public PropertyCallTarget $uninitialized; + public int $reads = 0; + public PropertyCallTarget $hooked { + get { + ++$this->reads; + return $this->target; + } + } + + public function __construct() + { + $this->target = new PropertyCallTarget(); + $this->polymorphic = new PropertyCallChild(); + } + + public function replace(): int + { + $this->target = new PropertyCallTarget(9); + return 3; + } + + public function checkHook(array &$events): array + { + $id = $this->hooked->record($events); + return [$id, $this->reads]; + } + + public function checkUninitialized(array &$events): string + { + try { + $this->uninitialized->record($events); + } catch (Error $error) { + return 'uninitialized'; + } + return 'unexpected'; + } + + public function run(array &$events): array + { + $first = $this->target->record($events, $this->replace()); + $second = $this->target->record(value: 2, events: $events); + return [$first, $second, $this->polymorphic->value(), $this->nullable?->record($events)]; + } +} + +class PrivateMethodPropertyBase +{ + private function value(): string + { + return 'base'; + } +} + +final class PrivateMethodPropertyChild extends PrivateMethodPropertyBase +{ + public function value(): string + { + return 'child'; + } +} + +final class PrivateMethodPropertyHolder +{ + public PrivateMethodPropertyBase $target; + + public function run(): string + { + return $this->target->value(); + } +} + +interface InterfacePropertyRecorder +{ + public function record(array &$events): void; +} + +final class InterfacePropertyRecorderImpl implements InterfacePropertyRecorder +{ + public function record(array &$events): void + { + $events[] = 'recorded'; + } +} + +final class InterfacePropertyHolder +{ + public InterfacePropertyRecorder $target; + + public function run(array &$events): void + { + $this->target->record($events); + } +} + +final class StaticPropertyCallTarget +{ + public function record(array &$events): void + { + $events[] = 'recorded'; + } +} + +final class StaticPropertyCallHolder +{ + public static StaticPropertyCallTarget $target; + + public static function run(array &$events): void + { + self::$target->record($events); + } +} + +function unrelatedRecordSignature(array &$events): void +{ +} + +function dynamicReceiverRecordCall(mixed $target, array $events): void +{ + $target->unrelatedRecordSignature($events); +} + +final class MissingSignaturePropertyTarget +{ +} + +function missingSignaturePropertyMethod(array &$events): void +{ +} + +final class MissingSignaturePropertyHolder +{ + public MissingSignaturePropertyTarget $target; + + public function run(array $events): void + { + $this->target->missingSignaturePropertyMethod($events); + } +} + +class LexicalPrivatePropertyBase +{ + public LexicalPrivateFinalPropertyChild $finalTarget; + public LexicalPrivateOpenPropertyChild $openTarget; + + private function record(array &$events): void + { + $events[] = 'base'; + } + + public function runFinal(array &$events): void + { + $this->finalTarget->record($events); + } + + public function runOpen(array &$events): void + { + $this->openTarget->record($events); + } +} + +final class LexicalPrivateFinalPropertyChild extends LexicalPrivatePropertyBase +{ + public function record(array $events): void + { + } +} + +class LexicalPrivateOpenPropertyChild extends LexicalPrivatePropertyBase +{ + public function record(array $events): void + { + } +} + +final class MagicPrivatePropertyTarget +{ + private function hidden(array &$events): void + { + } + + public function __call(string $name, array $arguments): void + { + } +} + +final class MagicPrivatePropertyHolder +{ + public MagicPrivatePropertyTarget $target; + + public function run(array $events): void + { + $this->target->hidden($events); + } +} + +final class MagicMissingPropertyTarget +{ + public function __call(string $name, array $arguments): void + { + } +} + +final class MagicMissingPropertyHolder +{ + public MagicMissingPropertyTarget $target; + + public function run(array $events): void + { + $this->target->missing($events); + } +} + +function main(): void +{ + $holder = new PropertyCallHolder(); + $events = []; + $result = $holder->run($events); + $hook = $holder->checkHook($events); + echo json_encode([$result, $events, $holder->checkUninitialized($events), $hook], JSON_THROW_ON_ERROR), "\n"; +} diff --git a/phpunit/src/TypedPropertyMethodCallTest.php b/phpunit/src/TypedPropertyMethodCallTest.php new file mode 100644 index 00000000..45369dac --- /dev/null +++ b/phpunit/src/TypedPropertyMethodCallTest.php @@ -0,0 +1,143 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + self::assertIsString($code); + self::assertSame(1, preg_match( + '/php::Array php_propertycallholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertSame(2, substr_count($matches['body'], 'php_propertycalltarget__record(')); + self::assertStringNotContainsString('php_propertycallbase__value(', $matches['body']); + } + + public function testNonFinalPropertyReceiverUsesRuntimeDispatch(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/php::\w+ php_privatemethodpropertyholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertStringNotContainsString('php_privatemethodpropertybase__value(', $matches['body']); + self::assertMatchesRegularExpression('/(?:typephp_call_method(?:_scoped)?_cached|\.call)\(/', $matches['body']); + } + + public function testInterfacePropertyReceiverUsesInterfaceReferenceSignature(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/void php_interfacepropertyholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertMatchesRegularExpression('/(?:RefWrap|toReference|\.ref\()/i', $matches['body']); + self::assertMatchesRegularExpression('/(?:typephp_call_method_cached|\.call)\(/', $matches['body']); + } + + public function testStaticPropertyReceiverUsesDirectCallWithReferenceArguments(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/void php_staticpropertycallholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertStringContainsString('php_staticpropertycalltarget__record(', $matches['body']); + } + + public function testUnknownReceiverDoesNotUseSameNamedGlobalFunctionSignature(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/void php_dynamicreceiverrecordcall\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertStringNotContainsString('RefWrap', $matches['body']); + self::assertStringNotContainsString('.ref()', $matches['body']); + } + + public function testMissingTypedPropertySignatureDoesNotUseSameNamedGlobalFunction(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/void php_missingsignaturepropertyholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertStringNotContainsString('RefWrap', $matches['body']); + self::assertStringNotContainsString('.ref()', $matches['body']); + } + + public function testLexicalPrivateMethodWinsOverPropertyClassMethod(): void + { + $code = $this->compileFixture(); + foreach (['runfinal', 'runopen'] as $method) { + self::assertSame(1, preg_match( + '/void php_lexicalprivatepropertybase__' . $method . '\\(.*?\\) \\{(?.*?)\\n\\}/s', + $code, + $matches, + )); + self::assertStringNotContainsString('php_lexicalprivatepropertybase__record(', $matches['body']); + self::assertStringNotContainsString('php_lexicalprivatefinalpropertychild__record(', $matches['body']); + self::assertStringNotContainsString('php_lexicalprivateopenpropertychild__record(', $matches['body']); + self::assertStringContainsString('typephp_call_method_scoped_cached(', $matches['body']); + self::assertMatchesRegularExpression('/(?:RefWrap|toReference|\.ref\()/i', $matches['body']); + } + } + + public function testFinalPropertyPrivateMethodUsesMagicRuntimeDispatch(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/void php_magicprivatepropertyholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertStringNotContainsString('php_magicprivatepropertytarget__hidden(', $matches['body']); + self::assertMatchesRegularExpression('/(?:typephp_call_method(?:_scoped)?_cached|\.call)\(/', $matches['body']); + self::assertStringNotContainsString('RefWrap', $matches['body']); + self::assertStringNotContainsString('.ref()', $matches['body']); + } + + public function testFinalPropertyMissingMethodRetainsDirectMagicOptimization(): void + { + $code = $this->compileFixture(); + self::assertSame(1, preg_match( + '/void php_magicmissingpropertyholder__run\(.*?\) \{(?.*?)\n\}/s', + $code, + $matches, + )); + self::assertStringContainsString('php_magicmissingpropertytarget____call(', $matches['body']); + self::assertStringNotContainsString('typephp_call_method', $matches['body']); + self::assertStringNotContainsString('RefWrap', $matches['body']); + } + + private function compileFixture(): string + { + global $translator; + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/typed-property-method-call.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + self::assertIsString($code); + return $code; + } +} diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index f1fe3ae3..54abc3de 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -166,6 +166,64 @@ protected function isFinalClass(string $class): bool return $this->hasClass($class) && ($this->getClass($class)->flags & Modifiers::FINAL) !== 0; } + /** + * A typed property declaration can describe call arguments without proving + * the receiver's concrete runtime class. Private methods are the one + * exception: a child may declare an unrelated public method with the same + * name and a different signature, so an inaccessible private declaration + * cannot be used as the runtime call's signature contract. + */ + protected function getTypedPropertyMethodSignatureClass(string $class, string $method): string + { + if ($class === '' || $method === '') { + return ''; + } + + if ($this->hasInterface($class)) { + return $this->findAotMethodFunctionDef($class, $method) !== null ? $class : ''; + } + if (!$this->hasClass($class)) { + return ''; + } + + $classDef = $this->getClass($class); + while (true) { + if ($classDef->hasMethod($method) || $classDef->hasAbstractMethod($method)) { + return $this->checkAccessible($classDef, $classDef->getMethodFlags($method)) ? $class : ''; + } + if (!$classDef->extends || !$this->hasClass($classDef->extends)) { + break; + } + $classDef = $this->getClass($classDef->extends); + } + + // A class may inherit its declaration from an implemented interface. + return $this->findAotMethodFunctionDef($class, $method) !== null ? $class : ''; + } + + /** + * A final property class is exact enough for a native call only when its + * resolved method is accessible from the current lexical scope. An + * inaccessible method may instead dispatch to __call() at runtime. + */ + protected function hasInaccessibleTypedPropertyMethod(string $class, string $method): bool + { + if ($class === '' || $method === '' || !$this->hasClass($class)) { + return false; + } + + $classDef = $this->getClass($class); + while (true) { + if ($classDef->hasMethod($method) || $classDef->hasAbstractMethod($method)) { + return !$this->checkAccessible($classDef, $classDef->getMethodFlags($method)); + } + if (!$classDef->extends || !$this->hasClass($classDef->extends)) { + return false; + } + $classDef = $this->getClass($classDef->extends); + } + } + protected function getMethodFlags(string $class, string $method): int { if (!$this->hasClass($class)) { @@ -546,6 +604,9 @@ protected function parseMethodCall(Expr\MethodCall $expr): string $class = ''; $materializedNativeReceiver = false; + $materializedTypedPropertyReceiver = false; + $typedPropertyReceiver = false; + $typedPropertyFinalClass = ''; // C++17 sequences a member-call receiver before its arguments, but // lowering an argument may hoist captured beforeStmtLines ahead of the // whole call. Materialize an effectful receiver before parsing args. @@ -554,6 +615,42 @@ protected function parseMethodCall(Expr\MethodCall $expr): string $object = $this->materializeNativeObjectReceiver($expr->var, $receiverClass); $class = $receiverClass; $materializedNativeReceiver = true; + } elseif (($expr->var instanceof Expr\PropertyFetch || $expr->var instanceof Expr\StaticPropertyFetch) + && $this->isIdExpr($expr->var->name)) { + // A non-nullable declared object property supplies a method + // signature, but only a final class proves the concrete receiver + // needed for a direct native call. Materialize either kind of + // property once before arguments (including hook getters). + $resolvedStaticProperty = false; + if ($expr->var instanceof Expr\PropertyFetch) { + $this->getPropertyIdentifier($expr->var, $expr->var->var, $expr->var->name); + } else { + $resolution = $this->resolveNativeStaticPropertyFetch($expr->var); + $resolvedStaticProperty = $resolution !== null && $resolution->class !== null; + } + $property = $this->getNativePropertyDef($expr->var); + if ($property !== null + && $property->type === Type::OBJECT + && !$property->nullable + && $property->class !== '' + && ($this->hasClass($property->class) || $this->hasInterface($property->class)) + && !$this->isNativeObjectClass($property->class) + && ($expr->var instanceof Expr\PropertyFetch || $resolvedStaticProperty) + ) { + $object = $this->parseOrderedOperand($expr->var, false, true); + $class = $property->class; + $typedPropertyReceiver = true; + if ($this->isFinalClass($property->class)) { + $typedPropertyFinalClass = $property->class; + } + } else { + $object = empty($expr->args) + ? $this->parseIdentifier($expr->var) + : $this->parseOrderedOperand($expr->var, false); + if (empty($expr->args)) { + $object = '(' . $object . ')'; + } + } } else { $object = empty($expr->args) ? $this->parseIdentifier($expr->var) @@ -627,6 +724,38 @@ protected function parseMethodCall(Expr\MethodCall $expr): string // Re-checking isNamedMethod() does not prove the earlier assignment to // static analyzers and previously left the object path uninitialized. $methodName = $this->isNamedMethod($expr->name) ? $expr->name->toString() : ''; + $typedPropertyLexicalPrivateClass = ''; + if ($typedPropertyReceiver + && $methodName !== '' + && $this->classDef !== null + && $this->methodDef !== null + ) { + $scopeClass = $this->classDef->getNamespacedName(false); + // Private methods are lexically scoped. Check only this class's + // own declaration: an inherited private method has a different + // lexical scope and must not affect this call. + if ($this->classDef->hasMethod($methodName) + && ($this->classDef->getMethodFlags($methodName) & Modifiers::PRIVATE) + && $this->isSameOrSubclassOf($class, $scopeClass) + ) { + $typedPropertyLexicalPrivateClass = $scopeClass; + } + } + $typedPropertyHasInaccessibleMethod = $typedPropertyReceiver + && $typedPropertyLexicalPrivateClass === '' + && $this->hasInaccessibleTypedPropertyMethod($class, $methodName); + $typedPropertySignatureClass = $typedPropertyReceiver + ? ($typedPropertyLexicalPrivateClass !== '' + ? $typedPropertyLexicalPrivateClass + : $this->getTypedPropertyMethodSignatureClass($class, $methodName)) + : $class; + if ($typedPropertyFinalClass !== '' + && $typedPropertyLexicalPrivateClass === '' + && !$typedPropertyHasInaccessibleMethod + ) { + $this->addObject($object, $typedPropertyFinalClass); + $materializedTypedPropertyReceiver = true; + } $pythonFacadeCall = $this->parsePythonNativeFacadeMethodCall($expr, $object); if ($pythonFacadeCall !== null) { @@ -700,7 +829,8 @@ protected function parseMethodCall(Expr\MethodCall $expr): string } // Method calls that can be lowered to a native call - if (($this->isVarExpr($expr->var) || $materializedNativeReceiver) and $this->isNamedMethod($expr->name)) { + if (($this->isVarExpr($expr->var) || $materializedNativeReceiver || $materializedTypedPropertyReceiver) + and $this->isNamedMethod($expr->name)) { $type = $this->getVarType($object); if ($class !== '' && $this->isNativeObjectClass($class)) { // Native objects have their own C++ virtual thunk for an @@ -870,7 +1000,7 @@ protected function parseMethodCall(Expr\MethodCall $expr): string $funcName, $magicMethod, $this->isVarExpr($expr->var) && $this->parseIdentifier($expr->var) === 'this_', - ); + ) || $typedPropertyLexicalPrivateClass !== ''; $resolvedMethodPtr = false; if ($class && $funcName && !$magicMethod) { if ($this->isInternalClass($class)) { @@ -910,7 +1040,16 @@ protected function parseMethodCall(Expr\MethodCall $expr): string try { $class = empty($class) ? self::DYNAMIC_CALLED_CLASS : $class; if (!$resolvedMethodPtr) { - $callArgs = $this->parseCallArgs($expr->args, $funcName, $class); + $callArgClass = $typedPropertyReceiver + ? ($typedPropertySignatureClass === '' + ? self::DYNAMIC_CALLED_CLASS + : $typedPropertySignatureClass) + : $class; + $callArgs = $this->parseCallArgs( + $expr->args, + $funcName, + $callArgClass, + ); if ($requiresDynamicScope && $this->methodDef) { if (!$cacheMethod) { return 'php::callScoped(' . $object . ', ' . $methodPtr . ', ' diff --git a/tests/compiler/devirtualize/typed-property-interface-reference.phpt b/tests/compiler/devirtualize/typed-property-interface-reference.phpt new file mode 100644 index 00000000..008fb7b2 --- /dev/null +++ b/tests/compiler/devirtualize/typed-property-interface-reference.phpt @@ -0,0 +1,44 @@ +--TEST-- +Interface typed property receiver preserves by-reference arguments +--FILE-- +target = new TypedPropertyEventRecorderImpl(); + } + + public function run(array &$events): void + { + $this->target->record($events); + } +} + +function main(): void +{ + $events = []; + $holder = new TypedPropertyInterfaceHolder(); + $holder->run($events); + echo json_encode($events, JSON_THROW_ON_ERROR), "\n"; +} + +?> +--EXPECT-- +["recorded"] diff --git a/tests/compiler/devirtualize/typed-property-private-override.phpt b/tests/compiler/devirtualize/typed-property-private-override.phpt new file mode 100644 index 00000000..dc5bdce7 --- /dev/null +++ b/tests/compiler/devirtualize/typed-property-private-override.phpt @@ -0,0 +1,44 @@ +--TEST-- +Typed property receiver dispatches a child public method over a parent private method +--FILE-- +target = new TypedPropertyPrivateChild(); + } + + public function run(): string + { + return $this->target->value(); + } +} + +function main(): void +{ + echo (new TypedPropertyPrivateHolder())->run(), "\n"; +} + +?> +--EXPECT-- +child diff --git a/tests/compiler/devirtualize/typed-property-receiver.phpt b/tests/compiler/devirtualize/typed-property-receiver.phpt new file mode 100644 index 00000000..991ab21a --- /dev/null +++ b/tests/compiler/devirtualize/typed-property-receiver.phpt @@ -0,0 +1,88 @@ +--TEST-- +Typed property method calls preserve references, receiver order, overrides, hooks and uninitialized access +--FILE-- +id + $value; + return $this->id; + } +} + +class PropertyCallBase +{ + public function value(): int { return 1; } +} + +final class PropertyCallChild extends PropertyCallBase +{ + public function value(): int { return 2; } +} + +final class PropertyCallHolder +{ + public PropertyCallTarget $target; + public PropertyCallBase $polymorphic; + public ?PropertyCallTarget $nullable = null; + public PropertyCallTarget $uninitialized; + public int $reads = 0; + public PropertyCallTarget $hooked { + get { + ++$this->reads; + return $this->target; + } + } + + public function __construct() + { + $this->target = new PropertyCallTarget(); + $this->polymorphic = new PropertyCallChild(); + } + + public function replace(): int + { + $this->target = new PropertyCallTarget(9); + return 3; + } + + public function checkHook(array &$events): array + { + $id = $this->hooked->record($events); + return [$id, $this->reads]; + } + + public function checkUninitialized(array &$events): string + { + try { + $this->uninitialized->record($events); + } catch (Error $error) { + return 'uninitialized'; + } + return 'unexpected'; + } + + public function run(array &$events): array + { + $first = $this->target->record($events, $this->replace()); + $second = $this->target->record(value: 2, events: $events); + return [$first, $second, $this->polymorphic->value(), $this->nullable?->record($events)]; + } +} + +function main(): void +{ + $holder = new PropertyCallHolder(); + $events = []; + $result = $holder->run($events); + $hook = $holder->checkHook($events); + echo json_encode([$result, $events, $holder->checkUninitialized($events), $hook], JSON_THROW_ON_ERROR), "\n"; +} + +?> +--EXPECT-- +[[1,9,2,null],[4,11,9],"uninitialized",[9,1]] diff --git a/tests/compiler/devirtualize/typed-static-property-reference.phpt b/tests/compiler/devirtualize/typed-static-property-reference.phpt new file mode 100644 index 00000000..6aa48ac1 --- /dev/null +++ b/tests/compiler/devirtualize/typed-static-property-reference.phpt @@ -0,0 +1,34 @@ +--TEST-- +Non-nullable typed static property receiver preserves by-reference arguments +--FILE-- +record($events); + } +} + +function main(): void +{ + TypedStaticPropertyHolder::$target = new TypedStaticPropertyTarget(); + $events = []; + TypedStaticPropertyHolder::run($events); + echo json_encode($events, JSON_THROW_ON_ERROR), "\n"; +} + +?> +--EXPECT-- +["recorded"]