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
master
韩天峰 4 days ago
parent cb1e12f11f
commit 4713a58b06
  1. 26
      docs/OBJECT_CREATION.md
  2. 29
      phpunit/src/NewObjectCodegenTest.php
  3. 48
      src/CompilerBase.php
  4. 4
      src/Preprocessor.php
  5. 11
      src/Translator.php
  6. 22
      tests/compiler/functions/default-array-new-object.phpt
  7. 48
      tests/compiler/object_property/runtime-default-array-cow.phpt

@ -135,19 +135,19 @@ TypePHP 父类已经安装自定义 allocator 时,普通子类通常直接继
在 micro benchmark 中,仅包含标量属性的 `new Foo()` 已从约 `1.8s` 降至约 `0.78s`,与同环境 ZendPHP 扣除空循环后的约 `0.83s` 接近。该数字只用于记录优化量级,不是跨机器性能承诺。 在 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` 模板和一个初始化状态,NTS 构建不引入锁;
1. 每个包含运行时数组默认值的类拥有一组 `THREAD_LOCAL php::Var` 模板和一个初始化状态;
2. 第一次创建该类对象时,通过 `UNEXPECTED(!initialized)` 惰性构建该类的全部模板; 2. 第一次创建该类对象时,通过 `UNEXPECTED(!initialized)` 惰性构建该类的全部模板;
3. 后续创建对象时只把模板 zval 复制到目标 backing slot,即增加一次数组引用计数; 3. 模板全部在局部临时值中成功构建后才提交并设置初始化标记,构造异常不会发布半初始化状态;
4. 某个对象第一次修改该属性时,由 Zend/PHPX 的 `SEPARATE_ARRAY` 执行 copy-on-write; 4. 模板初始化发生在对象分配之前,失败时不会遗留一个尚未返回的对象;
5. 在 `php_app_clean()` 中释放模板并重置初始化状态,不能让 request allocator 分配的 HashTable 跨越 RSHUTDOWN。 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 规则。 若创建一万个对象但不修改 `$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。 不能简单地在 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 已改为固定属性槽写入 ### 7.2 已改为固定属性槽写入

@ -75,35 +75,46 @@ final class NewObjectCodegenTest extends \BaseTest
[, $extension] = $this->compileFixtureAndExtension(); [, $extension] = $this->compileFixtureAndExtension();
self::assertStringContainsString( self::assertStringContainsString(
'THREAD_LOCAL bool php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = false;', 'THREAD_LOCAL bool typephp_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = false;',
$extension, $extension,
); );
self::assertStringContainsString( self::assertStringContainsString(
'THREAD_LOCAL php::Var php_request_array_default_runtimearraydefaultcodegen__values;', 'THREAD_LOCAL php::Var typephp_request_array_default_runtimearraydefaultcodegen__values;',
$extension, $extension,
); );
self::assertStringContainsString( self::assertStringContainsString(
'THREAD_LOCAL php::Var php_request_array_default_runtimearraydefaultcodegen__labels;', 'THREAD_LOCAL php::Var typephp_request_array_default_runtimearraydefaultcodegen__labels;',
$extension, $extension,
); );
self::assertMatchesRegularExpression( 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, $extension,
); );
self::assertMatchesRegularExpression( $createObject = strpos(
'/create_object_RuntimeArrayDefaultCodegen[^=]*= \[\][\s\S]*php_ensure_request_array_defaults_RuntimeArrayDefaultCodegen\(\);[\s\S]*typephp_create_object_with_defaults/', $extension,
'php_class_entry_RuntimeArrayDefaultCodegen->create_object = [](zend_class_entry *class_type)',
);
self::assertIsInt($createObject);
$ensureDefaults = strpos(
$extension, $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( self::assertStringContainsString(
'= php_request_array_default_runtimearraydefaultcodegen__values;', '= typephp_request_array_default_runtimearraydefaultcodegen__values;',
$extension, $extension,
); );
self::assertStringContainsString( self::assertStringContainsString(
'php_request_array_default_runtimearraydefaultcodegen__values.unset();', 'typephp_request_array_default_runtimearraydefaultcodegen__values.unset();',
$extension, $extension,
); );
self::assertStringContainsString( self::assertStringContainsString(
'php_request_array_default_runtimearraydefaultcodegen__labels.unset();', 'typephp_request_array_default_runtimearraydefaultcodegen__labels.unset();',
$extension, $extension,
); );
} }

@ -426,6 +426,8 @@ class CompilerBase implements PropertyAccessContext
protected ?MethodDef $methodDef = null; protected ?MethodDef $methodDef = null;
protected ?InterfaceDef $interfaceDef = null; protected ?InterfaceDef $interfaceDef = null;
protected bool $inGeneratorBody = false; protected bool $inGeneratorBody = false;
/** Parameter-default helpers have no ordinary function-entry declaration block. */
protected bool $allowLocalClassEntryHoisting = true;
private ?DiagnosticReporter $diagnosticReporter = null; private ?DiagnosticReporter $diagnosticReporter = null;
protected FunctionContext $context; protected FunctionContext $context;
protected array $superGlobalVars = [ protected array $superGlobalVars = [
@ -1344,7 +1346,7 @@ class CompilerBase implements PropertyAccessContext
*/ */
protected function getLocalClassEntryPtr(string $className): string protected function getLocalClassEntryPtr(string $className): string
{ {
if (!$this->isProcessStableClass($className)) { if (!$this->allowLocalClassEntryHoisting || !$this->isProcessStableClass($className)) {
return $this->getClassEntryPtr($className); return $this->getClassEntryPtr($className);
} }
if (isset($this->context->classEntryPtrs[$className])) { if (isset($this->context->classEntryPtrs[$className])) {
@ -1356,6 +1358,17 @@ class CompilerBase implements PropertyAccessContext
return $entry; 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. */ /** The declaring class controls visibility; the runtime called class does not. */
protected function getCallableScopeExpr(): string protected function getCallableScopeExpr(): string
{ {
@ -1588,19 +1601,26 @@ class CompilerBase implements PropertyAccessContext
if (!$default) { if (!$default) {
return null; return null;
} }
/* return $this->withoutLocalClassEntryHoisting(function () use ($default): string {
* 函数参数默认值只能为字面量,无法使用表达式获取值。 /*
* 但 PHP 自 5.6 起支持在默认参数值中使用常量表达式,包括 * 函数参数默认值只能为字面量,无法使用表达式获取值。
* 类常量(self::FOO、ClassName::BAR、\Full\Class::BAZ), * 但 PHP 自 5.6 起支持在默认参数值中使用常量表达式,包括
* 编译器需要在编译期将其折叠为对应的字面量。 * 类常量(self::FOO、ClassName::BAR、\Full\Class::BAZ),
*/ * 编译器需要在编译期将其折叠为对应的字面量。
if ($default instanceof Expr\ConstFetch) { *
return $this->parseConstFetch($default, true); * PHP 8.1 also permits `new` in selected default-value contexts.
} * These expressions are emitted into standalone helper functions,
if ($default instanceof Expr\ClassConstFetch) { * so a class entry must remain in the helper expression rather than
return $this->parseClassConstFetch($default); * being hoisted into the containing function's entry block.
} */
return $this->parseIdentifier($default); 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 protected function getComment(Node\Stmt $v, string $class): string

@ -660,7 +660,9 @@ class Preprocessor extends CompilerBase
} }
if ($param->default) { if ($param->default) {
$arrayInitPlan = $param->default instanceof Node\Expr\Array_ $arrayInitPlan = $param->default instanceof Node\Expr\Array_
? $this->buildLiteralArrayInitPlan($param->default) ? $this->withoutLocalClassEntryHoisting(
fn (): ArrayInitPlan => $this->buildLiteralArrayInitPlan($param->default),
)
: null; : null;
if ($param->byRef) { if ($param->byRef) {
if ($this->isEmptyArray($param->default)) { if ($this->isEmptyArray($param->default)) {

@ -1994,17 +1994,17 @@ CODE;
private function getRequestArrayDefaultInitializedName(ClassDef $classDef): string 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 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 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); . $this->getNativeName($property->name, $classDef->namespace, $classDef->name);
} }
@ -2066,7 +2066,10 @@ CODE;
$plan = $property->arrayInitPlan; $plan = $property->arrayInitPlan;
$code .= ' do {' . PHP_EOL; $code .= ' do {' . PHP_EOL;
$code .= $this->indentGeneratedBlock($plan->init, 3); $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 .= $this->indentGeneratedBlock($plan->clean, 3);
$code .= ' } while (0);' . PHP_EOL; $code .= ' } while (0);' . PHP_EOL;
} }

@ -0,0 +1,22 @@
--TEST--
Array parameter defaults containing new expressions resolve classes inside the helper
--FILE--
<?php
class ArrayDefaultObject
{
public int $value = 42;
}
function readArrayDefault(array $values = [new ArrayDefaultObject()]): void
{
var_dump($values[0]->value);
}
function main(): void
{
readArrayDefault();
}
?>
--EXPECT--
int(42)

@ -0,0 +1,48 @@
--TEST--
Runtime array property defaults share request templates and separate on write
--FILE--
<?php
class RuntimeArrayCowDefaults
{
public array $options = [
'timeout' => 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"]]
Loading…
Cancel
Save