From 4713a58b066d16c8041db4b660cbf9f3c42f06a8 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 21 Aug 2026 18:50:17 +0800 Subject: [PATCH] feat(compiler): add local class entry hoisting control for parameter defaults - Introduce $allowLocalClassEntryHoisting flag to control class entry hoisting behavior - Add withoutLocalClassEntryHoisting method to temporarily disable hoisting during callback execution - Modify getLocalClassEntryPtr to respect hoisting allowance when checking process stable class - Wrap parameter default parsing in withoutLocalClassEntryHoisting to prevent unwanted hoisting - Update array initialization in Preprocessor to use hoisting control for literal arrays - Add support for new expressions in array parameter defaults with proper class resolution - Include tests for array parameter defaults containing new expressions - Add tests for runtime array property defaults with copy-on-write behavior - Rename generated request array default symbols with 'typephp_' prefix for consistency - Update documentation to reflect implemented optimizations and remaining costs --- docs/OBJECT_CREATION.md | 26 +++++----- phpunit/src/NewObjectCodegenTest.php | 29 +++++++---- src/CompilerBase.php | 48 +++++++++++++------ src/Preprocessor.php | 4 +- src/Translator.php | 11 +++-- .../functions/default-array-new-object.phpt | 22 +++++++++ .../runtime-default-array-cow.phpt | 48 +++++++++++++++++++ 7 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 tests/compiler/functions/default-array-new-object.phpt create mode 100644 tests/compiler/object_property/runtime-default-array-cow.phpt diff --git a/docs/OBJECT_CREATION.md b/docs/OBJECT_CREATION.md index 55163abc..973b3d34 100644 --- a/docs/OBJECT_CREATION.md +++ b/docs/OBJECT_CREATION.md @@ -135,19 +135,19 @@ TypePHP 父类已经安装自定义 allocator 时,普通子类通常直接继 在 micro benchmark 中,仅包含标量属性的 `new Foo()` 已从约 `1.8s` 降至约 `0.78s`,与同环境 ZendPHP 扣除空循环后的约 `0.83s` 接近。该数字只用于记录优化量级,不是跨机器性能承诺。 -## 7. 剩余性能成本与后续方向 +## 7. 已实现优化、剩余成本与后续方向 -### 7.1 非空数组应改为请求级模板与 copy-on-write +### 7.1 非空数组使用请求级模板与 copy-on-write -当前实现仍在每个对象中重新构建 `php::Array` 及所有元素,这是剩余的最大常见成本。不能把非空数组放进 internal class 的默认属性表,但这不等于必须为每个对象重新构建数组。 +不能把非空数组放进 internal class 的默认属性表,但这不等于必须为每个对象重新构建数组。当前生成器已经使用请求级默认值模板: -推荐生成一个请求级默认值模板: - -1. 每个包含运行时数组默认值的类拥有一组 `THREAD_LOCAL php::Var` 模板和一个初始化状态; +1. 每个包含运行时数组默认值的类拥有一组 `THREAD_LOCAL php::Var` 模板和一个初始化状态,NTS 构建不引入锁; 2. 第一次创建该类对象时,通过 `UNEXPECTED(!initialized)` 惰性构建该类的全部模板; -3. 后续创建对象时只把模板 zval 复制到目标 backing slot,即增加一次数组引用计数; -4. 某个对象第一次修改该属性时,由 Zend/PHPX 的 `SEPARATE_ARRAY` 执行 copy-on-write; -5. 在 `php_app_clean()` 中释放模板并重置初始化状态,不能让 request allocator 分配的 HashTable 跨越 RSHUTDOWN。 +3. 模板全部在局部临时值中成功构建后才提交并设置初始化标记,构造异常不会发布半初始化状态; +4. 模板初始化发生在对象分配之前,失败时不会遗留一个尚未返回的对象; +5. 后续创建对象时只把模板 zval 复制到目标 backing slot,即增加一次数组引用计数; +6. 某个对象第一次修改该属性时,由 Zend/PHPX 的 `SEPARATE_ARRAY` 执行 copy-on-write; +7. 在 `php_app_clean()` 中释放模板并重置初始化状态,request allocator 分配的 HashTable 不会跨越 RSHUTDOWN。 以如下默认值为例: @@ -163,15 +163,15 @@ class Request 若创建一万个对象但不修改 `$options`,数组及嵌套数组只构建一次;每个对象只持有共享 zval。若其中一个对象执行 `$request->options['timeout'] = 30`,只有该对象在写入时分离,其他对象和模板保持不变。嵌套数组也继续使用 Zend 原有的逐层 copy-on-write 规则。 -PHP 属性默认数组不能包含引用,允许出现在常量表达式中的对象主要是不可变的 enum case,因此共享模板符合默认属性语义。实现后仍须用 PHPT 覆盖顶层写入、嵌套写入、`unset`、引用写入和动态 ZendVM 写入,确认所有路径都会正确分离。 +PHP 属性默认数组不能包含引用,允许出现在常量表达式中的对象主要是不可变的 enum case,因此共享模板符合默认属性语义。PHPT 已覆盖顶层写入、嵌套写入、`unset`、引用写入和动态对象写入,确认这些路径都会正确分离。 不能简单地在 MINIT 构造持久化数组并传给 `zend_declare_typed_property()`。TypePHP 注册的是 `ZEND_INTERNAL_CLASS`,Zend 8.4 明确禁止 internal property 使用 refcounted default zval;`_object_properties_init()` 的 internal-class 快速路径也不会增加默认值引用计数。非空 array 与 enum object 都属于 refcounted value。 -因此,在不改变“TypePHP Class 注册为 internal class”这一基础设计、也不修改 Zend ABI 的前提下,非空数组仍不能进入 class default table;但可以在表外维护请求级模板,使数组构造成本从“每个对象一次”降为“每个请求、每个默认值一次”。 +因此,在不改变“TypePHP Class 注册为 internal class”这一基础设计、也不修改 Zend ABI 的前提下,非空数组仍不进入 class default table;表外请求级模板把数组构造成本从“每个对象一次”降为“每个请求、每个默认值一次”。未修改默认数组的对象只承担 zval 复制和引用计数成本,实际修改的对象才承担数组分离成本。 -模板建议按类惰性初始化,而不是在 RINIT 无条件构建全部模板:大型项目中很多类在一次请求内不会实例化。每个对象只增加一个高度可预测的初始化状态分支;第一次之后该分支稳定为 false。 +模板按类惰性初始化,而不是在 RINIT 无条件构建全部模板:大型项目中很多类在一次请求内不会实例化。每个对象只增加一个高度可预测的初始化状态分支;第一次之后该分支稳定为 false。 -第二阶段可以为完全由标量、字符串和嵌套字面量组成的数组生成模块生命周期的 persistent immutable template。它能进一步消除每个请求的一次构建,但需要正确处理 persistent HashTable、interned string、嵌套数组、MSHUTDOWN 和 ZTS,并把包含运行时常量或 enum case 的数组留在请求级路径。该方案侵入性和验证成本明显更高,不应作为第一阶段实现。 +暂不生成模块生命周期的 persistent immutable template。该方案需要完整验证 persistent HashTable、interned string、嵌套数组、MSHUTDOWN 和 ZTS,并且包含运行时常量或 enum case 的数组仍要走请求级路径。在 ZendVM 对这些组合的约束得到充分验证前,请求级模板是安全边界。 ### 7.2 已改为固定属性槽写入 diff --git a/phpunit/src/NewObjectCodegenTest.php b/phpunit/src/NewObjectCodegenTest.php index 0b5de62a..e5e65b37 100644 --- a/phpunit/src/NewObjectCodegenTest.php +++ b/phpunit/src/NewObjectCodegenTest.php @@ -75,35 +75,46 @@ final class NewObjectCodegenTest extends \BaseTest [, $extension] = $this->compileFixtureAndExtension(); self::assertStringContainsString( - 'THREAD_LOCAL bool php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = false;', + 'THREAD_LOCAL bool typephp_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = false;', $extension, ); self::assertStringContainsString( - 'THREAD_LOCAL php::Var php_request_array_default_runtimearraydefaultcodegen__values;', + 'THREAD_LOCAL php::Var typephp_request_array_default_runtimearraydefaultcodegen__values;', $extension, ); self::assertStringContainsString( - 'THREAD_LOCAL php::Var php_request_array_default_runtimearraydefaultcodegen__labels;', + 'THREAD_LOCAL php::Var typephp_request_array_default_runtimearraydefaultcodegen__labels;', $extension, ); self::assertMatchesRegularExpression( - '/if \(UNEXPECTED\(!php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen\)\) \{[\s\S]*prepared_default_0[\s\S]*prepared_default_1[\s\S]*php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = true;/', + '/if \(UNEXPECTED\(!typephp_request_array_defaults_initialized_RuntimeArrayDefaultCodegen\)\) \{[\s\S]*prepared_default_0[\s\S]*prepared_default_1[\s\S]*typephp_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = true;/', $extension, ); - self::assertMatchesRegularExpression( - '/create_object_RuntimeArrayDefaultCodegen[^=]*= \[\][\s\S]*php_ensure_request_array_defaults_RuntimeArrayDefaultCodegen\(\);[\s\S]*typephp_create_object_with_defaults/', + $createObject = strpos( + $extension, + 'php_class_entry_RuntimeArrayDefaultCodegen->create_object = [](zend_class_entry *class_type)', + ); + self::assertIsInt($createObject); + $ensureDefaults = strpos( $extension, + 'typephp_ensure_request_array_defaults_RuntimeArrayDefaultCodegen();', + $createObject, ); + self::assertIsInt($ensureDefaults); + $allocateObject = strpos($extension, 'typephp_create_object_with_defaults(', $ensureDefaults); + self::assertIsInt($allocateObject); + self::assertLessThan($ensureDefaults, $createObject); + self::assertLessThan($allocateObject, $ensureDefaults); self::assertStringContainsString( - '= php_request_array_default_runtimearraydefaultcodegen__values;', + '= typephp_request_array_default_runtimearraydefaultcodegen__values;', $extension, ); self::assertStringContainsString( - 'php_request_array_default_runtimearraydefaultcodegen__values.unset();', + 'typephp_request_array_default_runtimearraydefaultcodegen__values.unset();', $extension, ); self::assertStringContainsString( - 'php_request_array_default_runtimearraydefaultcodegen__labels.unset();', + 'typephp_request_array_default_runtimearraydefaultcodegen__labels.unset();', $extension, ); } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 777ff9dc..1a1bfb29 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -426,6 +426,8 @@ class CompilerBase implements PropertyAccessContext protected ?MethodDef $methodDef = null; protected ?InterfaceDef $interfaceDef = null; protected bool $inGeneratorBody = false; + /** Parameter-default helpers have no ordinary function-entry declaration block. */ + protected bool $allowLocalClassEntryHoisting = true; private ?DiagnosticReporter $diagnosticReporter = null; protected FunctionContext $context; protected array $superGlobalVars = [ @@ -1344,7 +1346,7 @@ class CompilerBase implements PropertyAccessContext */ protected function getLocalClassEntryPtr(string $className): string { - if (!$this->isProcessStableClass($className)) { + if (!$this->allowLocalClassEntryHoisting || !$this->isProcessStableClass($className)) { return $this->getClassEntryPtr($className); } if (isset($this->context->classEntryPtrs[$className])) { @@ -1356,6 +1358,17 @@ class CompilerBase implements PropertyAccessContext return $entry; } + protected function withoutLocalClassEntryHoisting(callable $callback): mixed + { + $allowLocalClassEntryHoisting = $this->allowLocalClassEntryHoisting; + $this->allowLocalClassEntryHoisting = false; + try { + return $callback(); + } finally { + $this->allowLocalClassEntryHoisting = $allowLocalClassEntryHoisting; + } + } + /** The declaring class controls visibility; the runtime called class does not. */ protected function getCallableScopeExpr(): string { @@ -1588,19 +1601,26 @@ class CompilerBase implements PropertyAccessContext if (!$default) { return null; } - /* - * 函数参数默认值只能为字面量,无法使用表达式获取值。 - * 但 PHP 自 5.6 起支持在默认参数值中使用常量表达式,包括 - * 类常量(self::FOO、ClassName::BAR、\Full\Class::BAZ), - * 编译器需要在编译期将其折叠为对应的字面量。 - */ - if ($default instanceof Expr\ConstFetch) { - return $this->parseConstFetch($default, true); - } - if ($default instanceof Expr\ClassConstFetch) { - return $this->parseClassConstFetch($default); - } - return $this->parseIdentifier($default); + return $this->withoutLocalClassEntryHoisting(function () use ($default): string { + /* + * 函数参数默认值只能为字面量,无法使用表达式获取值。 + * 但 PHP 自 5.6 起支持在默认参数值中使用常量表达式,包括 + * 类常量(self::FOO、ClassName::BAR、\Full\Class::BAZ), + * 编译器需要在编译期将其折叠为对应的字面量。 + * + * PHP 8.1 also permits `new` in selected default-value contexts. + * These expressions are emitted into standalone helper functions, + * so a class entry must remain in the helper expression rather than + * being hoisted into the containing function's entry block. + */ + if ($default instanceof Expr\ConstFetch) { + return $this->parseConstFetch($default, true); + } + if ($default instanceof Expr\ClassConstFetch) { + return $this->parseClassConstFetch($default); + } + return $this->parseIdentifier($default); + }); } protected function getComment(Node\Stmt $v, string $class): string diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 766d6795..516a900b 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -660,7 +660,9 @@ class Preprocessor extends CompilerBase } if ($param->default) { $arrayInitPlan = $param->default instanceof Node\Expr\Array_ - ? $this->buildLiteralArrayInitPlan($param->default) + ? $this->withoutLocalClassEntryHoisting( + fn (): ArrayInitPlan => $this->buildLiteralArrayInitPlan($param->default), + ) : null; if ($param->byRef) { if ($this->isEmptyArray($param->default)) { diff --git a/src/Translator.php b/src/Translator.php index 9f896e19..fff36921 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -1994,17 +1994,17 @@ CODE; private function getRequestArrayDefaultInitializedName(ClassDef $classDef): string { - return self::PREFIX . 'request_array_defaults_initialized_' . $classDef->getNamespacedName(); + return 'typephp_request_array_defaults_initialized_' . $classDef->getNamespacedName(); } private function getRequestArrayDefaultInitializerName(ClassDef $classDef): string { - return self::PREFIX . 'ensure_request_array_defaults_' . $classDef->getNamespacedName(); + return 'typephp_ensure_request_array_defaults_' . $classDef->getNamespacedName(); } private function getRequestArrayDefaultTemplateName(ClassDef $classDef, PropertyDef $property): string { - return self::PREFIX . 'request_array_default_' + return 'typephp_request_array_default_' . $this->getNativeName($property->name, $classDef->namespace, $classDef->name); } @@ -2066,7 +2066,10 @@ CODE; $plan = $property->arrayInitPlan; $code .= ' do {' . PHP_EOL; $code .= $this->indentGeneratedBlock($plan->init, 3); - $code .= " prepared_default_{$index} = {$plan->expr};" . PHP_EOL; + $code .= $this->indentGeneratedBlock( + "prepared_default_{$index} = {$plan->expr};", + 3, + ); $code .= $this->indentGeneratedBlock($plan->clean, 3); $code .= ' } while (0);' . PHP_EOL; } diff --git a/tests/compiler/functions/default-array-new-object.phpt b/tests/compiler/functions/default-array-new-object.phpt new file mode 100644 index 00000000..cc585760 --- /dev/null +++ b/tests/compiler/functions/default-array-new-object.phpt @@ -0,0 +1,22 @@ +--TEST-- +Array parameter defaults containing new expressions resolve classes inside the helper +--FILE-- +value); +} + +function main(): void +{ + readArrayDefault(); +} +?> +--EXPECT-- +int(42) diff --git a/tests/compiler/object_property/runtime-default-array-cow.phpt b/tests/compiler/object_property/runtime-default-array-cow.phpt new file mode 100644 index 00000000..bbdccca9 --- /dev/null +++ b/tests/compiler/object_property/runtime-default-array-cow.phpt @@ -0,0 +1,48 @@ +--TEST-- +Runtime array property defaults share request templates and separate on write +--FILE-- + 10, + 'headers' => ['Accept' => 'application/json'], + 'list' => [1, 2], + ]; + public array $tags = ['default']; +} + +function mutateDynamicObject(object $object): void +{ + $object->options['headers']['Accept'] = 'text/plain'; +} + +function main(): void +{ + $first = new RuntimeArrayCowDefaults(); + $second = new RuntimeArrayCowDefaults(); + $third = new RuntimeArrayCowDefaults(); + + $first->options['timeout'] = 30; + $first->options['headers']['X-Test'] = 'one'; + $first->tags[] = 'first'; + + $timeout =& $second->options['timeout']; + $timeout = 20; + unset($second->options['list'][0]); + + mutateDynamicObject($third); + + $fresh = new RuntimeArrayCowDefaults(); + echo json_encode([$first->options, $first->tags], JSON_UNESCAPED_SLASHES), "\n"; + echo json_encode([$second->options, $second->tags], JSON_UNESCAPED_SLASHES), "\n"; + echo json_encode([$third->options, $third->tags], JSON_UNESCAPED_SLASHES), "\n"; + echo json_encode([$fresh->options, $fresh->tags], JSON_UNESCAPED_SLASHES), "\n"; +} +?> +--EXPECT-- +[{"timeout":30,"headers":{"Accept":"application/json","X-Test":"one"},"list":[1,2]},["default","first"]] +[{"timeout":20,"headers":{"Accept":"application/json"},"list":{"1":2}},["default"]] +[{"timeout":10,"headers":{"Accept":"text/plain"},"list":[1,2]},["default"]] +[{"timeout":10,"headers":{"Accept":"application/json"},"list":[1,2]},["default"]]