16 KiB
AOT 编译器优化优先级重新评估:考虑 GCC/Clang 二次编译
核心原则
AOT 的优化价值不在于做 GCC/Clang 已经做了的事情,而在于提供 GCC/Clang 无法从 C++ 代码中推断出来的信息。
GCC/Clang 在 -O2/-O3 下已经能做的:
- 常量折叠、常量传播 (SCCP)
- 死代码消除 (DCE)
- 公共子表达式消除 (CSE)
- 循环展开、向量化
- 指令选择、寄存器分配
- 函数内联 (同一编译单元内)
- 分支预测优化
- SIMD 自动向量化
GCC/Clang 不能做的——因为语义被 php::Var、php::Object、虚函数调用等抽象层遮蔽:
- 将
php::Var窄化为具体 C++ 类型 - 消除
php::Object的虚调用 - 消除引用计数操作
- 将
php::Array从堆分配移到栈分配 - 将
php::BigInt降级为原生int64_t - 消除
php::call函数查找/分发开销 - 跨翻译单元的全局分析(LTO 部分可以,但受限于可见性)
重新排序的优先级
Tier 1: 直接改善 C++ 代码形状(GCC 无法修复)
这些优化改变的是生成出来的是什么 C++ 代码,而不是优化已达成的 C++ 代码。此处是 AOT 的最核心价值。
#1 类型推断 → 生成具体 C++ 类型而非 php::Var
问题: 当前 AOT 将大量变量映射为 php::Var(全局类型),GCC 只能对 php::Var 的成员函数做有限优化。
收益:
// 当前生成的代码 (GCC 能做的不多)
php::Var a = php::toInt(x);
php::Var b = php::toInt(y);
php::Var c = php::add(a, b); // GCC 看不到这是加法
// 类型推断后
int64_t a = php::toInt(x);
int64_t b = php::toInt(y);
int64_t c = a + b; // GCC 可以进行所有整数优化
对 GCC 二次编译的价值放大: 当类型变成 int64_t 后,GCC 可以进一步:
- 寄存器分配(不再走
php::Var的内存布局) - 常量折叠和传播
- 循环优化(循环内的
php::Var操作变成纯整数运算) - 自动 SIMD 化
实施建议: 这是最高优先级。不需要完整的 SSA 形式,只需在每个赋值点推断最精确的类型,并在 C++ 代码生成时使用该类型声明变量。已经做的 union/nullable 类型检查基础设施可以扩展为类型推断。
#2 去虚拟化 (Devirtualization)
问题: PHP 所有非 final 方法调用都是虚调用。AOT 生成 obj->method()(C++ 虚函数调用),即使只有一个子类实现了该方法。
收益:
// 当前生成 (虚调用,GCC 不敢消除 vtable 查找)
return self->foo(); // virtual call
// 去虚拟化后 (GCC 可以内联这个调用!)
return Aot_MyClass_foo(self); // direct function call
突破口: 编译期收集所有类及其继承关系。如果:
- 方法是 private → 永远直接调用
- 方法是 final → 永远直接调用
- 类在所有编译文件中只有 1 个非抽象实现 → 直接调用
self::foo()调用自己类的方法 → 可以直接调用(如果该类没有未发现的子类)
与 GCC 的协同: 一旦变成直接调用,GCC 可以:
- 内联整个函数体
- 跨函数常量传播
- 连锁消除后续的冗余操作
#3 逃逸分析 → 栈分配 + 引用计数消除
问题: 每个 new 对象/数组都在堆上分配,且通过引用计数管理生命周期。
收益:
// 当前 (堆分配 + refcount)
php::Array arr = php::newArray();
arr.set("key", value); // refcount 操作
return arr; // 拷贝 + refcount
// 逃逸分析后 (栈分配 + 无 refcount)
zend_array arr; // 栈上
zend_hash_update(&arr, "key", value); // 无 refcount
return php::Array::fromStack(std::move(arr)); // 仅在返回点打包
对 GCC 二次编译的影响: 这是对 GCC 帮助最大的优化,因为:
- 堆分配 → 栈分配:消除了
malloc调用,GCC 可以完全优化栈布局 - 消除 refcount:GCC 不需要分析互锁操作的副作用,可以自由重排指令
- GCC 可以在栈变量上做 SROA(Scalar Replacement of Aggregates),把数组/对象拆散成标量
实施建议: 即使是简单的局部逃逸分析(只分析对象是否被传到函数外部)也能消除大量分配。复杂版本(跨函数逃逸分析)收益继续增长。
#4 调用图驱动的跨文件内联
问题: 同一个 PHP 项目可能编译成多个 .cc 文件,GCC 的 LTO 可以跨文件内联但受限于编译时间。
收益: AOT 在编译时就知道整个调用图,可以在生成 C++ 代码时就决定内联:
// 当前
auto result = aot_smallHelper(x, y); // 函数调用,即使函数体只有一行
// 内联后
auto result = x + y; // GCC 可以继续优化
关键判断信息:
- 函数体大小(< 10 行 → 内联候选)
- 调用次数(只调用 1 次 → 内联可以完全消除函数)
- 递归标记(递归函数不内联)
- 跨函数常量传播机会(实参是常量 → 内联后 GCC 可以折叠整个函数)
与 GCC 的协同: AOT 做"决策"(该不该内联),把内联后的代码直接生成到调用点。GCC 在更大的内联体上继续做优化。AOT 掌握 GCC 不掌握的信息(来自调用图的全局视角)。
Tier 2: 为 GCC 提供更精确的类型信息
这些优化改善的是传递给 GCC 的 C++ 类型质量。
#5 整数范围推断 → 选择最优整数类型
收益:
// PHP 源码: 循环计数器,0 到 100
for ($i = 0; $i < 100; $i++) { ... }
// 当前 AOT 生成
int64_t i = 0; // 总是用 int64_t (防止溢出)
// 范围推断后
int8_t i = 0; // 0..100 足够,更好的缓存局部性
// 或者至少
int64_t i = 0; // 但标记 "no overflow possible",不插入 BigInt 升级
对 GCC 的影响:
- 更小的类型 → 更好的向量化(更多元素装入 SIMD 寄存器)
- "不会溢出" 的断言 → GCC 的 VRP (Value Range Propagation) 可以基于这个前提做更激进的优化
#6 避免不必要的 BigInt/BigFloat 分配
相关于 #5。 PHP 的整数运算在溢出时自动升级为浮点或 BigInt。如果 AOT 能证明不会溢出,就不需要生成升级代码。
// 当前 (每个 int 运算都要考虑溢出)
php::Var result = php::BigInt::add(php::toBigInt(a), php::toBigInt(b));
// 范围推断后 (不会有溢出)
int64_t result = a + b; // 纯整数,GCC 的世界
Tier 3: 与 GCC 部分重叠但仍有价值
#7 常量折叠(PHP 层级)
GCC 能做的: C++ 层面的常量表达式在 GCC 的 SCCP pass 中完全折叠。
AOT 独有的价值:
- PHP 特有的常量:
PHP_INT_MAX、PHP_VERSION、__DIR__等在编译时完全解析 - 跨 PHP 命名空间/类名的常量解析(GCC 看不到 PHP 的符号语义)
- PHP 内建函数的结果:
strlen("hello")→ 5(GCC 不知道strlen的内部实现但是可以折叠已知参数的函数)
评估: 有限价值。大部分 PHP 源码中没有复杂的编译时可求值常量表达式。
#8 死代码消除(PHP 层级)
GCC 能做的: GCC 的 DCE + unreachable block elimination 非常成熟。
AOT 独有的价值:
- 基于 PHP 类型系统消除分支:
if (false)在 PHP 层消除 → 不生成任何 C++ 代码 - 基于类型窄化消除分支:
if ($x instanceof Foo)当$x的类型已确定为Bar且Foo ⊄ Bar时,false 分支不可达 - 消除未被调用的 PHP 函数/类(跨文件死代码)
评估: 中等价值。AOT 应该关注"基于 PHP 语义的 DCE",而不是与 GCC 竞争"基于 C++ 语义的 DCE"。
Tier 4: 改善编译过程而非输出质量
#9 文件缓存(编译加速)
GCC 能做的: ccache。但 ccache 需要文件内容哈希匹配。
AOT 独有的价值:
- 缓存 PHP→C++ 的翻译结果(解析过的 AST + 类型信息)
- 跨重启复用
- 增量更新(只重编译改动的 PHP 文件)
评估: 有用但非核心。先让输出代码质量达到最优,再优化编译速度。
#10 Pass Pipeline 架构
工程架构层面的基础设施,提高代码可维护性和扩展性。不直接影响输出质量。
优先级汇总
必须做 (直接影响 GCC 看到的 C++ 类型):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⭐ #1 类型推断 → 避免 php::Var 泛化 【最大单点收益】
⭐ #2 去虚拟化 → 消除虚调用开销 【第二大收益】
⭐ #3 逃逸分析 → 栈分配 + 消除 refcount 【对数值计算代码收益极大】
强烈建议 (给 GCC 更好的前提条件):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⭐ #4 调用图内联 → 跨越翻译单元边界
⭐ #5 范围推断 → 最优整数类型选择
⭐ #6 消除 BigInt/BigFloat 不必要分配
锦上添花 (有独特价值但非核心):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#7 PHP 层常量折叠
#8 PHP 语义层 DCE
#9 编译缓存加速
工程基础:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#10 Pass Pipeline 架构
#1 类型推断的进一步分析
这是最大单点收益项,值得进一步展开。以下是具体的技术路径:
关键洞察:不需要完整 SSA
AOT 是生成 C++ 代码,不是运行优化后的字节码。这意味着:
- 不需要 φ 函数:C++ 的变量赋值天然就是 SSA 的"新鲜变量"形式
- 不需要 dominance frontier 计算:C++ 编译器会自己做
- 只需要在每个表达式计算其可能的类型,生成对应的 C++ 类型声明
当前问题
PHP 源码: $a = 1;
$b = $a + 2;
生成的 C++: php::Var a = php::toInt(php::toVar(1));
php::Var b = php::add(a, php::toVar(2)); // !!全 Var!!
此时 GCC 看到的:
- 所有值都是
php::Var(一个复杂结构体) - 运算通过
php::add()函数(外部符号,不可内联) - 无法确定类型,无法窄化
目标
PHP 源码: $a = 1;
$b = $a + 2;
生成的 C++: int64_t a = 1;
int64_t b = a + 2; // GCC 可以完整优化
实现路径
Phase A: 局部类型传播(~300 行)
遍历 AST,在每个赋值点:
- 如果 RHS 是字面量 → 类型明确,记录
- 如果 RHS 是已知类型的变量 → 传播类型
- 如果 RHS 是二元运算且两操作数类型已知 → 推断结果类型
- 如果变量被多次赋值为不同类型 → 退化为
php::Var
Phase B: 条件窄化(~200 行,依赖 TypeSpecifier)
if ($x instanceof MyClass) { // 进入此分支时 $x 的取值确定是 MyClass
$x->method(); // 这里可以用直接调用
}
Phase C: 跨基本块交汇(~200 行)
if (cond) { $x = 1; } else { $x = 2; }
// 交汇点: $x 是 int64_t (两个分支都是整数)
类型窄化的安全性约束:PHP instanceof 语义分析
问题
用户指出:if ($x instanceof Foo) 在 PHP 中的语义是——$x 可能是 Foo 的实例或其任何子类的实例。这与 C++ 的 dynamic_cast<Foo*> 相同。
这意味着:
if ($x instanceof Foo) {
$x->method(); // 虚调用!$x 的实际类可能重写了 method()
}
不能因为窄化为 Foo 就把 $x->method() 转为直接调用 Foo::method()——除非 Foo 是 final 类或 method 是 private。
窄化的三个安全层级
层级 1: 类型成员可访问性(总是安全)
即使不知道精确类型,只要知道 $x 是 Foo 的子类型,就可以:
- 确认
$x有某个方法/属性的访问能力 - 消除不可能的代码路径(
if ($x instanceof Foo)在$x已知为Bar且Foo与Bar不相关时为 false) - 生成正确的 C++ 类型标注(
Foo*而非void*/php::Var)
// 窄化前: $x 是 php::Var,调用 foo() 可能要抛异常
// 窄化后: $x 是 Foo 的某个子类型,一定有 foo() 方法
$x->foo(); // 可以确认方法存在(虽然仍是虚调用)
收益: 消除方法不存在的运行时检查,改进错误检测(编译期发现)。
层级 2: final 类 + private 方法(完全安全)
| 场景 | 可窄化为精确类型? | 可直接调用? |
|---|---|---|
$x instanceof FinalClass |
是 | 是 |
$x->privateMethod() |
是 (private 不可重写) | 是 |
self::method() 且类无子类 |
是 | 是 |
$x instanceof NonFinalClass |
否 | 否 |
final class Logger {
public function log(string $msg): void { ... }
}
if ($x instanceof Logger) {
$x->log("test"); // 安全:直接调用 Aot_Logger_log($x, "test")
}
层级 3: 有限子类集合 + 守卫(guard-based devirtualization)
当 Foo 不是 final,但 AOT 编译了整个代码库,可以确定所有 Foo 的子类:
已知类层次:
Foo (abstract)
├── SubFooA method() 实现
└── SubFooB method() 实现
此时 $x instanceof Foo 意味着 $x 只能是 SubFooA 或 SubFooB。可以生成:
// 守卫式去虚拟化
if (x.getInstanceOf(php_get_class(SubFooA))) {
Aot_SubFooA_method(x); // 直接调用
} else {
Aot_SubFooB_method(x); // 直接调用(最后一种不用判断)
}
// 替代原来的: x->method(); (虚调用)
收益: 对于已知有限子类的类层次,用 if-else 分发替代虚表查找。如果只有 1 个子类(de facto final),则完全消除分支。
PHP 自身的处理方式
Zend SSA 的 zend_ssa_var_info 结构体精确地追踪了这个区别:
typedef struct _zend_ssa_var_info {
uint32_t type;
zend_class_entry *ce;
bool is_instanceof : 1; // 0 = class == ce, 1 = may be child of ce
// ...
} zend_ssa_var_info;
is_instanceof = false:变量精确是这个类(来自new Foo()或get_class($x) === 'Foo'检查)is_instanceof = true:变量可能是这个类或其子类(来自$x instanceof Foo)
只有 is_instanceof = false 且 ce 非 abstract 时,才能安全地去虚拟化。
对 Tier 1 优化的影响
| 优化 | 受 instanceof 语义影响? | 有效范围 |
|---|---|---|
| #1 类型推断 (生成具体 C++ 类型) | 不直接受影响 | 窄化为 Foo* 仍然有价值(比 php::Var 好) |
| #2 去虚拟化 | 是——instanceof 不提供精确类型 | 仅 final 类、private 方法、有限子类守卫 |
| #3 逃逸分析 | 不直接受影响 | 不需要精确类型,只需逃逸/非逃逸判断 |
| #4 调用图内联 | 不直接受影响 | 调用图分析的是函数关系,与 instanceof 窄化无关 |
| #5 范围推断 | 不直接受影响 | 整数范围与 OOP 继承无关 |
关键结论
instanceof 窄化虽然不足以确定性地去虚拟化所有调用,但它提供了类型成员可见性的保证,可以消除方法查找、改进错误检测、以及生成更精确的 C++ 类型签名(Foo* vs php::Var)。
真正能推动去虚拟化的信息来自:
- Final 类 — 由源码声明提供
- 全程序类层次分析 — 编译所有代码后可知哪些类是 de facto final(无子类在代码库中)
- 调用点特定的类型推断 — 从
new Foo()赋值追踪到调用点
结论
在 GCC/Clang 二次编译的架构下,AOT 编译器的优化策略应该是:
- 把 PHP 的动态类型信息"冻结"为静态 C++ 类型——这是 GCC 无法自己做的
- 把虚调用"落地"为直接调用——让 GCC 可以内联
- 把堆分配"提升"为栈分配——让 GCC 可以 SROA
- 不要与 GCC 竞争 SSA 层面的优化——SCCP、DCE、CSE 留给 GCC
- 关注跨翻译单元的信息——调用图、类层次——这是 LTO 也难以覆盖的