Skip to content

perf(compiler): read declared scalar properties through typed offsets - #188

Open
yuan-dian wants to merge 2 commits into
swoole:masterfrom
yuan-dian:perf/aot-declared-scalar-property-read
Open

yuan-dian wants to merge 2 commits into
swoole:masterfrom
yuan-dian:perf/aot-declared-scalar-property-read

Conversation

@yuan-dian

Copy link
Copy Markdown
Contributor

背景

AOT 编译器把“已声明的 int/float 实例属性读取”降级为 attr(offset, AttrMode::Get)。即使偏移来自 get_persistent_prop()(无哈希查找),该读法仍会为每次读取构造一个 Variant,随后立刻被 toInt() / toFloat() 解包,并带有引用计数与临时变量 unset() 的开销,热点循环里这类读取占比很高。

本 PR 在属性确定为“原生标量存储的已声明 int/float”时,直接读 zval slot。

复现

final class Row
{
    public function __construct(
        public int $id,
        public float $weight,
    ) {}
}

function tally(Row $row, int $iterations): float
{
    $sum = 0.0;
    for ($i = 0; $i < $iterations; $i++) {
        $sum += $row->weight + $row->id;
    }
    return $sum;
}

改动前生成(每次迭代都构造并清理 Variant):

tmp_var_0 = row.attr(get_persistent_prop(PersistentPropertyId{0}, get_str(4), get_str(0)), php::AttrMode::Get);
// ...
tmp_var_0.unset();
tmp_var_1.unset();

改动后:

tmp_var_0 = php::toFloat(row.attrFloat(get_persistent_prop(PersistentPropertyId{0}, get_str(4), get_str(0))));
tmp_var_1 = php::toInt(row.attrInt(get_persistent_prop(PersistentPropertyId{1}, get_str(2), get_str(0))));

根因

parsePropertyFetch() 中,$this 之外的 类型化对象变量只有在属性为 readonly 时才会走 emitNativeInstancePropertyTypedFetch() 的 convertExprFromType() 分支;普通(非 readonly)已声明标量属性落回通用路径,生成 attr(offset, Get) + Variant 临时变量。而 phpx 早已提供 attrInt() / attrFloat()(OBJ_PROP + unwrap_zval,返回引用,不构造 Variant)。

改动

src/Parser/PropertyAccessTrait.php 新增 emitDeclaredScalarPropertyRead(),在 parsePropertyFetch() 的原生标量分支之前尝试拦截;命中则设置 NATIVE_PROPERTY_VALUE_VAR 与 ATTR_LOWERED_TYPE,生成 attrInt(offset) / attrFloat(offset)。

仅在以下条件全部满足时生效:

  • 读访问(!$update),属性为 public、非 static,无 getter/setter、非 virtual(无属性 hook)
  • 类型为 int / float,且 usesNativeScalarStorage() 为真(排除 varInt)
  • 非 readonly:readonly 保留 Zend 的“未初始化访问”检查
  • 非 nullable:attrInt / attrFloat 对 null 抛 TypeError
  • 接收者是精确类:final 类中的 $this,或声明类型为 final 的类型化对象变量——避免子类重声明属性导致持久化偏移漂移
  • 接收者不是 #[Native] 类

非 final 接收者、readonly、nullable、带 hook 的属性全部保持原有生成路径不变。

为什么只覆盖 int / float

这是“能证明与原路径逐位等价”的最小集合,而不是类型支持的终点:

  • phpx 只提供这两个标量 accessor。 Object::attrInt() / attrFloat()(include/phpx.h:2677 / :2688)返回 Int& / Float&,直接映射 zval 的 IS_LONG / IS_DOUBLE 存储布局,因此可以只读地读取 slot 本身,不构造 Variant。当前头文件里不存在 attrBool / attrStr / attrArray。
  • bool 无法复用 attrInt。 php::Bool 是 typedef bool(include/phpx_types.h:38,1 字节),而属性槽是 zval(IS_TRUE / IS_FALSE)。attrInt() 在 Z_TYPE_P(value) != IS_LONG 时会抛 TypeError,所以 bool 必须新增 attrBool,且由于 C++ bool 与 zval 布局不同,只能返回值或代理对象,无法像 Int&/Float& 那样返回引用。生成端目前仍是 php::toBool(this_.attr(...)) / php::toBool($obj.attr(offset, Get)),emitNativeInstancePropertyTypedFetch() 的 Type::BOOL 分支也明确走 convertBoolExpr()。
  • string 本质上不存在“无装箱”形态。 class String : public Variant(include/phpx.h:1846),读字符串天生就是持有/复制一个 zval;即使新增 accessor 也无法消除这层表示,收益远小于 int/float,却要先处理 refcount 生命周期(临时值销毁、返回引用、写入别名),语义风险不对称。
  • array / object 更远。 两者都是引用计数且带引用语义;typed array / std container 还走 nativeStorageType 的独立存储,根本不在对象 zval slot 上,需要单独的读写路径。
  • 收益集中在数值属性。 普查中 scoped 读的 1,791 处里 1,277 处紧跟 toFloat、326 处紧跟 toInt;3,773 处 attr(offset, Get) 也以数值属性为主。先落地类型可判定、存储布局一一对应的 int/float,才能在不引入新 phpx API、不改运行时语义的前提下拿到主要收益;bool/string 的扩展顺序是 phpx 先提供 Bool &attrBool,再评估 Str &attrStr。

readonly、nullable、带 hook 属性不在本节讨论范围内——它们是属性语义限制(初始化检查、null、属性访问器),与类型无关。

测试

  • PHPT(tests/compiler/object_property/)4 个:
    • declared-scalar-read-final-class.phpt:final 类,类型化参数循环 + $this 方法
    • declared-scalar-read-default-initialization.phpt:无默认值读作 0 / 0.0
    • declared-scalar-read-nullable.phpt:?int 的 null / 42 / null
    • declared-scalar-read-subclass-redeclare.phpt:非 final 基类 + 子类重声明,保证偏移不回退为错误读取
  • PHPUnit phpunit/src/DeclaredScalarPropertyReadTest.php + 4 个 phpunit/code/ fixture:
    • 断言 final 接收者生成 attrInt(get_persistent_prop( / attrFloat(get_persistent_prop( / this_.attrInt( / this_.attrFloat(
    • 断言非 final、nullable 仍生成 attr(get_persistent_prop(,readonly 仍生成 typephp_read_property_scoped(

验证

  • 新增 PHPT 4/4 PASS;tests/compiler/object_property 全量 74/74 PASS(含 readonly / hook / native property 用例);tests/compiler/{switch,devirtualize} 31/31 PASS
  • 新增 PHPUnit OK (4 tests, 12 assertions);全量 PHPUnit 3640 tests,仅有 7 个既有/环境性问题(5 个 CompilationStatisticsTest 缺 swoole/php-nano、2 个 clang codegen 缺 phpx VarList/PHPX_CATCH 符号),已在 master 上复现同样失败
  • 端到端探针(probes/typed_property_read,20M 次读取,-O2,best-of-3):1169ms → 125ms(约 9.4x),输出逐字节一致 sum=170000000;生成代码中 attr(get_persistent_prop( 2 → 0,attrInt(/attrFloat( 各 0 → 1
  • 语义等价性抽查:引用赋值 $ref = &$box->count; $ref = 9; 仍指向同一 slot(attrInt(...).toReference());null 接收者与 master 一样抛 Error: Attempt to read property on null
  • php -l、php-cs-fixer --dry-run(改动部分无新增偏差)、phpstan(新增代码无新增报错)通过

风险与兼容性

  • 只影响“已声明 int/float、非 readonly、非 nullable、无 hook、final 精确接收者”的读路径;其余生成代码不变
  • 类型范围是有意收窄的:仅 int/float(且非 varInt)。bool/string/array/object 仍走 Variant 路径,需 phpx 新增对应 accessor 后再扩展;本 PR 不引入 phpx 变更,也不改变这些类型的运行时语义
  • 不新增依赖,不改变运行时语义
  • final 限制是保守策略:偏移安全依赖精确类。放宽到有类守卫的接收者留给后续处理

Property reads of declared int/float scalars were lowered to
attr(offset, AttrMode::Get), which materializes a Variant on every read and
adds refcount traffic plus a temporary that the following conversion has to
unwrap. Read the zval slot directly through attrInt/attrFloat instead when the
property is stored as a native scalar.

The receiver must be an exact class so the persistent property offset cannot
drift when a subclass redeclares the property: `$this` in a final class, or a
typed object variable whose declared class is final. Readonly properties keep
the Zend initialization check, nullable properties may hold null (which the
typed accessors reject), and properties with hooks keep the existing lowering,
so all of those stay on the Variant path.

The probe under probes/typed_property_read measures 20M reads of two declared
scalars at 1169ms before and 125ms after on this machine, with identical
output; generated code confirms attr(get_persistent_prop(...)) drops to zero
and typed offset reads appear instead.
The typed offset read assumed that a proven runtime class also means Object
storage. A `mixed` parameter narrowed by instanceof disproves that: the class
is known, but the C++ binding is still a Variant, which has no attrInt or
attrFloat member, so instanceof-type-narrowing.phpt failed to compile.

Require Object storage for non-this receivers before emitting the typed
accessor; Variant bindings fall back to attr(offset, AttrMode::Get). Union
typed object parameters keep Object storage and still take the fast path.

Cover both directions: a PHPT for a narrowed mixed receiver, and a codegen
assertion that the fallback read is emitted instead of attrInt.
@yuan-dian

Copy link
Copy Markdown
Contributor Author

接收者还要求 C++ 存储为 php::Object:mixed 参数经 instanceof 收窄只证明运行时类,不改变 Variant 存储,这类读取继续走 attr()(由 instanceof-type-narrowing.phpt 覆盖)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant