diff --git a/benchmark/dynamic-call/README.md b/benchmark/dynamic-call/README.md index b5bad1da..c24a2416 100644 --- a/benchmark/dynamic-call/README.md +++ b/benchmark/dynamic-call/README.md @@ -20,14 +20,20 @@ The `scoped_*` cases exercise private/protected dynamic calls that must resolve with the compiled method's lexical scope. They are kept separate because a scoped cache must guard both the target callable and its calling scope. -The `static_*_dynamic` cases exercise direct `$class::method()`, -`Class::$method()`, and `$class::$method()` syntax. These sites still construct -their callable string dynamically, but now reuse the request-local resolution -slot instead of repeating `zend_is_callable_ex()` on every iteration. +The `static_*_dynamic` cases exercise direct `$class::fixedMethod()`, +`Class::$method()`, and `$class::$method()` syntax. PHPX resolves the class and +method independently through Zend's public class handlers, avoiding a +temporary `"Class::method"` callable string. Dynamic static dispatch is not +cached: only a source-level fixed class is lowered to a reusable class entry. +The corresponding `*_alternating` cases model route-like inputs where the +class or method changes at the same call site. The monomorphic string-call cases cover zero, one, two, and four positional arguments. This separates callable-cache lookup cost from argument -materialization cost and protects the small stack-argument fast path. +materialization cost. Fixed positional arguments are emitted as a contiguous +`std::array` and passed through PHPX without constructing the +dynamic `php::Args` vector. Calls containing argument unpacking continue to +use `php::Args`/`php::Array` because their final size is only known at runtime. Run it from the repository root against a release PHP/PHPX build: diff --git a/benchmark/dynamic-call/benchmark.php b/benchmark/dynamic-call/benchmark.php index 3e270c72..a425190b 100644 --- a/benchmark/dynamic-call/benchmark.php +++ b/benchmark/dynamic-call/benchmark.php @@ -67,6 +67,11 @@ final class DynamicCallTarget return $value + 1; } + public static function addTwoStatic(int $value): int + { + return $value + 2; + } + public function addTwo(int $value): int { return $value + 2; @@ -95,6 +100,16 @@ final class DynamicCallTarget final class DynamicCallAlternateTarget { + public static function addOne(int $value): int + { + return $value + 1; + } + + public static function addTwoStatic(int $value): int + { + return $value + 2; + } + public function hitOne(int $value): int { return $value + 1; @@ -290,6 +305,37 @@ function runDynamicStaticClassAndMethodCall(int $iterations): int return $sum; } +function runAlternatingDynamicStaticClassCall(int $iterations): int +{ + $sum = 0; + for ($i = 0; $i < $iterations; $i++) { + $class = ($i & 1) === 0 ? DynamicCallTarget::class : DynamicCallAlternateTarget::class; + $sum += $class::addOne($i); + } + return $sum; +} + +function runAlternatingDynamicStaticMethodCall(int $iterations): int +{ + $sum = 0; + for ($i = 0; $i < $iterations; $i++) { + $method = ($i & 1) === 0 ? 'addOne' : 'addTwoStatic'; + $sum += DynamicCallTarget::$method($i); + } + return $sum; +} + +function runAlternatingDynamicStaticClassAndMethodCall(int $iterations): int +{ + $sum = 0; + for ($i = 0; $i < $iterations; $i++) { + $class = ($i & 1) === 0 ? DynamicCallTarget::class : DynamicCallAlternateTarget::class; + $method = ($i & 1) === 0 ? 'addOne' : 'addTwoStatic'; + $sum += $class::$method($i); + } + return $sum; +} + function runObjectMethodArrayCall(int $iterations): int { $target = new DynamicCallTarget(); @@ -412,6 +458,9 @@ function runDynamicCallCase(string $case, int $iterations): int 'static_class_dynamic' => runDynamicStaticClassCall($iterations), 'static_method_dynamic' => runDynamicStaticMethodCall($iterations), 'static_class_method_dynamic' => runDynamicStaticClassAndMethodCall($iterations), + 'static_class_alternating' => runAlternatingDynamicStaticClassCall($iterations), + 'static_method_alternating' => runAlternatingDynamicStaticMethodCall($iterations), + 'static_class_method_alternating' => runAlternatingDynamicStaticClassAndMethodCall($iterations), 'object_method_array' => runObjectMethodArrayCall($iterations), 'invokable_object' => runInvokableObjectCall($iterations), 'method_name_monomorphic' => runMonomorphicMethodNameCall($iterations), @@ -465,6 +514,9 @@ function main(): void 'static_class_dynamic', 'static_method_dynamic', 'static_class_method_dynamic', + 'static_class_alternating', + 'static_method_alternating', + 'static_class_method_alternating', 'object_method_array', 'invokable_object', 'method_name_monomorphic', diff --git a/benchmark/dynamic-call/run.php b/benchmark/dynamic-call/run.php index 58901fff..ef6ce762 100644 --- a/benchmark/dynamic-call/run.php +++ b/benchmark/dynamic-call/run.php @@ -139,6 +139,9 @@ $cases = [ 'static_class_dynamic', 'static_method_dynamic', 'static_class_method_dynamic', + 'static_class_alternating', + 'static_method_alternating', + 'static_class_method_alternating', 'object_method_array', 'invokable_object', 'method_name_monomorphic', diff --git a/docs/en/SCOPE_MANAGEMENT.md b/docs/en/SCOPE_MANAGEMENT.md index 89923477..1ecbfffa 100644 --- a/docs/en/SCOPE_MANAGEMENT.md +++ b/docs/en/SCOPE_MANAGEMENT.md @@ -103,6 +103,10 @@ If a method never uses scoped dynamic calls, first-class callables, or scoped ca ### 3.5 Usage Entry Points +#### `typephp_call_cached()` / `typephp_call_method_cached()` + +TypePHP assigns one request-local slot to each unresolved function or object-method call site. String callables cache their resolved `zend_function`; method calls additionally guard the receiver class. Non-string callables, transient magic trampolines, and relative `self::` / `parent::` / `static::` strings continue through Zend's full resolver. + #### `typephp_call_method_scoped_cached()` Used for dynamic object method calls. It uses `CallableScope::resolve()` to obtain a `zend_fcall_info_cache`, retains cacheable results in a request-local call-site slot, then executes `zend_call_function()`. @@ -339,13 +343,17 @@ save EG(fake_scope) 7. When adding a PHP built-in function that synchronously invokes callbacks, update the callback argument description table, noting the position, argument name, and whether it is a callback map. 8. Functions that save a callback but do not invoke it immediately must not mark the scope fallback merely because they receive a callable, for example `spl_autoload_register()`. 9. When adding a `FakeScopeGuard` usage that crosses a Zend bailout, code review must check whether `zend_catch` explicitly restores it. +10. Request-local call slots must be destroyed before project request symbols are cleared. Do not retain Closure objects, receiver objects, or trampolines in those slots. ## 8. Performance Model | Path | Main Cost | Optimization Strategy | | --- | --- | --- | | `CallableScope` | Initializing one synthetic frame | At most once per AOT method, reused across loops | +| `typephp_call_cached()` | Resolving a dynamic string callable | One request-local slot per call site; non-string and relative callables remain dynamic | +| `typephp_call_method_cached()` | Resolving a method name against a runtime object | Cache only a guarded monomorphic target; disable the slot after its class or name changes | | `typephp_call_method_scoped_cached()` | Guarded cache lookup; `zend_is_callable_at_frame()` on a miss | One request-local slot per dynamic call site; resolvable Native Calls do not enter this path | +| Stable static property | Resolving class/property metadata and its Zend slot | Lazily cache only the final `zval*` in the generated C++ function; rebuild a lightweight `Variant` view on every access | | `prepareScopedCallback()` | One callable resolution | Public absolute callbacks do not create a Closure | | `makeScopedCallable()` | Callable resolution and Closure allocation | Used only for first-class callables | | `UserCodeScopeGuard` | One pointer lookup and write at method entry, plus restoration at exit | Generated only for `call_user_func*`, callback maps, or unresolved unpack callbacks | diff --git a/docs/zh-cn/SCOPE_MANAGEMENT.md b/docs/zh-cn/SCOPE_MANAGEMENT.md index 10cbbb61..9f0f8a87 100644 --- a/docs/zh-cn/SCOPE_MANAGEMENT.md +++ b/docs/zh-cn/SCOPE_MANAGEMENT.md @@ -103,6 +103,10 @@ php::CallableScope tmp_var_1 = php::getCallableScope( ### 3.5 使用入口 +#### `typephp_call_cached()` / `typephp_call_method_cached()` + +TypePHP 为每个未解析的函数或对象方法调用点分配一个 request 级 slot。字符串 callable 缓存解析后的 `zend_function`;方法调用还会校验 receiver class。非字符串 callable、临时 magic trampoline,以及相对的 `self::` / `parent::` / `static::` 字符串仍走 Zend 完整解析路径。 + #### `typephp_call_method_scoped_cached()` 用于动态对象方法调用。它使用 `CallableScope::resolve()` 获取 `zend_fcall_info_cache`,将允许缓存的结果保存在 request 级调用点 slot 中,然后执行 `zend_call_function()`。 @@ -339,13 +343,17 @@ save EG(fake_scope) 7. 新增会同步调用 callback 的 PHP 内置函数时,需要更新 callback 参数描述表,注明位置、参数名以及是否为 callback map。 8. 保存 callback 但不立即调用的函数不能仅因接收 callable 就标记 scope fallback,例如 `spl_autoload_register()`。 9. 新增跨 Zend bailout 的 `FakeScopeGuard` 用法时,代码审查必须检查 `zend_catch` 是否显式恢复。 +10. request 级调用 slot 必须先于项目 request 符号清理而析构;slot 不得持有 Closure 对象、receiver 对象或 trampoline。 ## 8. 性能模型 | 路径 | 主要成本 | 优化策略 | | --- | --- | --- | | `CallableScope` | 初始化一个 synthetic frame | 每个 AOT 方法最多一次,循环复用 | +| `typephp_call_cached()` | 解析动态字符串 callable | 每个调用点一个 request 级 slot;非字符串及相对 callable 仍保持动态解析 | +| `typephp_call_method_cached()` | 根据运行时对象解析方法名 | 只缓存带 class/name guard 的单态目标;调用点发生变化后禁用 slot | | `typephp_call_method_scoped_cached()` | 带 guard 的缓存查找;未命中时执行 `zend_is_callable_at_frame()` | 每个动态调用点一个 request 级 slot;可解析的 Native Call 不进入此路径 | +| 稳定静态属性 | 解析类/属性元数据及 Zend slot | 仅在生成的 C++ 函数内惰性缓存最终 `zval*`;每次访问重新构造轻量 `Variant` view | | `prepareScopedCallback()` | 一次 callable 解析 | public 绝对 callback 不创建 Closure | | `makeScopedCallable()` | callable 解析及 Closure 分配 | 仅 first-class callable 使用 | | `UserCodeScopeGuard` | 方法入口一次指针查找、写入和退出恢复 | 只为 `call_user_func*`、callback map 或未解析的 unpack callback 生成 | diff --git a/phpunit/src/CallCacheCodegenTest.php b/phpunit/src/CallCacheCodegenTest.php index b3ca497d..fe2ce967 100644 --- a/phpunit/src/CallCacheCodegenTest.php +++ b/phpunit/src/CallCacheCodegenTest.php @@ -1,7 +1,10 @@ 1]); + $method = new ReflectionMethod($compiler, 'assertCallArgumentLimit'); + (new ReflectionProperty($compiler, 'file'))->setValue($compiler, 'argument-limit.php'); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('A function call cannot contain more than 65536 arguments'); + $method->invoke($compiler, array_fill(0, 65_537, $argument)); + } } diff --git a/phpunit/src/SymbolTest.php b/phpunit/src/SymbolTest.php index f853c410..4055f3d8 100644 --- a/phpunit/src/SymbolTest.php +++ b/phpunit/src/SymbolTest.php @@ -47,6 +47,11 @@ class SymbolTest extends TestCase $this->assertEquals('php::ArgList', Symbol::argList()); } + public function testVarList(): void + { + $this->assertEquals('php::VarList', Symbol::varList()); + } + public function testGetCalledCe(): void { $this->assertSame('typephp_get_called_ce(this_)', Symbol::getCalledCe()); diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index e772e236..a08ba673 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -20,6 +20,9 @@ use TypePhp\Generator\Symbol; trait CallArgumentGenerator { + /** Guard against a broken lowering path producing an unbounded call. */ + private const CALL_ARGUMENT_LIMIT = 65_536; + protected function parseNativeCallArgs( array $callArgs, string $nativeFunc, @@ -27,6 +30,7 @@ trait CallArgumentGenerator bool $deferTrailingDefaults = false, ): string { + $this->assertCallArgumentLimit($callArgs); $functionDef = $this->getFunction($nativeFunc); $providedArgs = []; $defaultArgs = []; @@ -414,6 +418,7 @@ trait CallArgumentGenerator bool $preserveExistingReferences = false ): string { + $this->assertCallArgumentLimit($args); $list_args = []; $arrayArgsVar = null; $argsVar = null; @@ -580,7 +585,12 @@ trait CallArgumentGenerator if ($arrayArgsVar !== null) { return $namedArgsVar !== null ? $arrayArgsVar . ', ' . $namedArgsVar . '.array()' : $arrayArgsVar; } - $callArgs = Symbol::argList() . '{' . implode(', ', $list_args) . '}'; + // VarList deduces the fixed argument count and owns contiguous + // Variant storage, which PHPX passes directly to Zend without a + // dynamic php::Args allocation. materializeCallArgValue() above + // ensures that ordinary values do not leave INDIRECT borrows in the + // list; explicit reference arguments remain references. + $callArgs = Symbol::varList() . '{' . implode(', ', $list_args) . '}'; return $namedArgsVar !== null ? $callArgs . ', ' . $namedArgsVar . '.array()' : $callArgs; } @@ -667,7 +677,7 @@ trait CallArgumentGenerator 'Native objects cannot cross a dynamic PHP/ZendVM call boundary' ); } - // C++17 evaluates php::ArgList{...} elements from left to right, but a + // C++17 evaluates fixed argument array elements from left to right, but a // later argument may emit captured beforeStmtLines while being lowered. // Those statements are placed before the whole outer call and would // overtake an earlier Call left inside the initializer list. Complete @@ -692,9 +702,9 @@ trait CallArgumentGenerator // A call that returns by reference yields a live php::Ref aliasing the // callee's storage. When such a call feeds a by-value argument, PHP takes // a value snapshot at evaluation time (left to right), so later mutations - // to the aliased storage must not be observable. The dynamic ArgList keeps - // references verbatim (Ctor::CopyRef), so we dereference into a temporary - // value at the point of the call. + // to the aliased storage must not be observable. PHPX argument container + // constructors preserve explicit references, so dereference into a + // temporary value at the point of an ordinary by-value call. $expr = $this->materializeRefReturnAsValue($value, $expr); if (!$this->shouldMaterializeCallArg($value)) { return $expr; @@ -711,6 +721,17 @@ trait CallArgumentGenerator return $value instanceof Expr\PropertyFetch; } + protected function assertCallArgumentLimit(array $args): void + { + if (count($args) <= self::CALL_ARGUMENT_LIMIT) { + return; + } + $this->fatalError( + $args[self::CALL_ARGUMENT_LIMIT], + 'A function call cannot contain more than 65536 arguments', + ); + } + protected function parseReferenceCallArgValue(Node\Arg $arg): string { if ($this->isReferenceWrapperCall($arg->value)) { diff --git a/src/Generator/Symbol.php b/src/Generator/Symbol.php index 0a935ee2..7129ae21 100644 --- a/src/Generator/Symbol.php +++ b/src/Generator/Symbol.php @@ -65,6 +65,11 @@ class Symbol return 'php::ArgList'; } + public static function varList(): string + { + return 'php::VarList'; + } + public static function safeIndex(string $index, int|string $size): string { return "php::safeIndex({$index}, {$size})"; diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index b3bafc54..ebcbe414 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -865,6 +865,9 @@ trait MethodCallTrait return 'typephp_call_method_scoped_cached(' . $object . ', ' . $methodPtr . ', ' . $this->getCallableScopeExpr() . ', ' . $this->getMethodCallCache() . ')'; } + // The method is already a stable zend_function* from the + // project symbol cache. A second callable cache would only + // add guards before the same direct call. return 'php::callScoped(' . $object . ', ' . $methodPtr . ', ' . $this->getCallableScopeExpr() . ')'; } if (!$resolvedMethodPtr) { @@ -948,14 +951,15 @@ trait MethodCallTrait } /** - * Materialize a dynamic static-call target exactly once and normalize it - * to the runtime class name accepted by PHP callbacks. + * Materialize a dynamic static-call target exactly once before evaluating + * arguments. The snapshot is required even for a plain variable because + * an argument may mutate that variable by reference. * * PHP permits both an object and a class-name string before `::`. A * declared object type is only an upper bound, so using it directly would * lose late static binding when the runtime object is a subclass. */ - private function materializeDynamicStaticCallClassName(Expr $target): string + private function materializeDynamicStaticCallTarget(Expr $target): string { [$value, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($target); $this->appendCapturedStmtLinesToContext($beforeStmts); @@ -963,7 +967,7 @@ trait MethodCallTrait $this->context->beforeStmtLines[] = $classVar . ' = ' . $value . ';'; $this->appendCapturedStmtLinesToContext($afterStmts); - return '(' . $classVar . '.isObject() ? php::fn::get_class(' . $classVar . ') : php::toString(' . $classVar . '))'; + return $classVar; } /** @@ -1033,6 +1037,9 @@ trait MethodCallTrait $rtFunc = ''; $rtClass = ''; $cacheCallable = false; + $directStaticCall = false; + $staticCallTarget = ''; + $staticCallMethod = ''; $canUseDirectCallScope = $this->isNameExpr($expr->class) && $this->isIdExpr($expr->name); $class = ($this->isNameExpr($expr->class) || $this->isVarExpr($expr->class)) ? $this->parseIdentifier($expr->class) @@ -1061,8 +1068,11 @@ trait MethodCallTrait $class = $this->getObjectType($class); goto _do_call; } - $className = $this->materializeDynamicStaticCallClassName($expr->class); - $fn = 'php::concat({' . $className . ', "::", ' . $this->methodNameToStr($expr->name) . '})'; + $classTarget = $this->materializeDynamicStaticCallTarget($expr->class); + $staticCallTarget = $classTarget; + $staticCallMethod = $this->methodNameToStr($expr->name, literal: true); + $fn = 'php::concat({(' . $classTarget . '.isObject() ? php::fn::get_class(' . $classTarget + . ') : php::toString(' . $classTarget . ')), "::", ' . $staticCallMethod . '})'; if ($this->isVarExpr($expr->class) && $this->isIdExpr($expr->name)) { $declaredClass = $this->getDeclaredObjectType($class); if ($declaredClass !== '') { @@ -1074,11 +1084,26 @@ trait MethodCallTrait } } $placeHolder = $fn; - $cacheCallable = true; + $directStaticCall = true; } elseif ($this->isVarExpr($expr->name)) { - $fn = 'php::concat({' . $this->identifierToStr($expr->class) . ', "::", ' . $this->methodNameToStr($expr->name) . '})'; + $staticCallMethod = $this->methodNameToStr($expr->name, literal: true); + if ($class === 'static') { + $staticCallTarget = $this->getCalledCeExpr(); + } elseif ($class !== 'self') { + $resolvedClass = $this->getNamespacedClassName($class); + $staticCallTarget = $this->getLocalClassEntryPtr($resolvedClass); + } + $fn = 'php::concat({' . $this->identifierToStr($expr->class) . ', "::", ' . $staticCallMethod . '})'; $placeHolder = $fn; - $cacheCallable = true; + if ($staticCallTarget !== '') { + $directStaticCall = true; + } else { + // `self::$method()` carries a lexical lookup class and a + // potentially different late-bound called scope. Keep the + // existing scoped callable resolution until the lookup class + // and called scope can both be represented explicitly. + $cacheCallable = true; + } } elseif ($class === 'static') { if ($this->classDef?->nativeObject) { $this->fatalError( @@ -1170,12 +1195,19 @@ trait MethodCallTrait } if (empty($expr->args)) { + if ($directStaticCall) { + return 'php::callStaticMethod(' . $staticCallTarget . ', ' . $staticCallMethod . ')'; + } if ($cacheCallable) { return 'typephp_call_cached(' . $fn . ', ' . $this->getFunctionCallCache() . ')'; } return 'php::call(' . $fn . ')'; } try { + if ($directStaticCall) { + return 'php::callStaticMethod(' . $staticCallTarget . ', ' . $staticCallMethod . ', ' + . $this->parseCallArgs($expr->args, $rtFunc, $rtClass) . ')'; + } if ($cacheCallable) { return 'typephp_call_cached(' . $fn . ', ' . $this->getFunctionCallCache() . ', ' . $this->parseCallArgs($expr->args, $rtFunc, $rtClass) . ')'; diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index e7f338cf..c3e32da3 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -413,6 +413,10 @@ trait PropertyAccessTrait if ($resolution !== null) { $property = $this->propertyNameToStr($expr->name, literal: true); + // Reference acquisition must run getStaticPropertyRef(): it + // converts the live slot to IS_REFERENCE and attaches a typed + // property's zend_property_info as a reference type source. The + // ordinary value-slot cache deliberately does neither operation. if ($resolution->class !== null) { $classPtr = $this->getClassEntryPtr($resolution->class); return Symbol::getStaticPropertyRef() . '(' . $classPtr . ', ' . $property . ')'; diff --git a/tests/compiler/dynamic_call/call-cache-arguments.phpt b/tests/compiler/dynamic_call/call-cache-arguments.phpt index 6194e8df..aa9bcba0 100644 --- a/tests/compiler/dynamic_call/call-cache-arguments.phpt +++ b/tests/compiler/dynamic_call/call-cache-arguments.phpt @@ -1,5 +1,5 @@ --TEST-- -Dynamic call cache preserves small, large, named, unpacked, reference, and exception arguments +Dynamic call cache preserves fixed, named, unpacked, reference, and exception arguments --FILE-- value, $values[0], 3, 4, 5)); + $arguments = [1, 2, 3, 4, 5]; var_dump($sum(...$arguments)); @@ -45,6 +54,7 @@ function main(): void int(10) int(15) int(10) +int(25) int(15) int(11) int(11) diff --git a/tests/compiler/static/static-call-dynamic-class-order.phpt b/tests/compiler/static/static-call-dynamic-class-order.phpt new file mode 100644 index 00000000..fdfb08f3 --- /dev/null +++ b/tests/compiler/static/static-call-dynamic-class-order.phpt @@ -0,0 +1,55 @@ +--TEST-- +dynamic static class target is evaluated before call arguments +--FILE-- + +--EXPECT-- +argument +string(11) "first:value" +string(11) "second:next" +class +argument +string(11) "first:value"