TypePHP 编译器
https://swoole.com/aot/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
224 lines
18 KiB
224 lines
18 KiB
# TypePHP 编译实现分析、提速方案与 mago 替代评估
|
|
|
|
> 生成日期:2026-08-03
|
|
> 适用范围:`swoole/typephp` v0.4.3(本仓库 `D:\git\php\aot-compiler`)
|
|
> 说明:所有耗时数字均来自本仓库实际基准测试(`src/` 下 139 个 PHP 文件、约 1.65 MB 源码,PHP 8.4.16 CLI;端到端对比用 302 文件合成项目,Windows + MSVC)
|
|
|
|
---
|
|
|
|
## 0. 实施状态(2026-08-03 更新)
|
|
|
|
**S1(合并两次解析)与 S2(writeFile 内容比对 + 生成 .cc 增量缓存)已实施并验证。**
|
|
|
|
### 已落地的改动
|
|
|
|
| 文件 | 改动 |
|
|
|---|---|
|
|
| `src/CompilerBase.php` | 新增 `parseCachedAst()` / `cloneAst()`(AST 内存缓存 + CloningVisitor 深拷贝);`writeFile()` 增加内容比对(内容相同不覆盖,返回 bool 保持 mtime 稳定) |
|
|
| `src/Preprocessor.php` | `prepareFile()` 改用 `parseCachedAst()`,消除与 convert 的重复 parse |
|
|
| `src/Translator.php` | `doConvert()` 改用 `parseCachedAst()`;新增 `hasGeneratedObjectFileCache()` / `getGeneratedObjectCacheKey()` / `getGeneratedHeaderDependencies()` / `isGeneratedSourceFile()`;`compileFile()` 对生成的 .cc 走对象缓存;`save()`/`genExtension()` 仅在实际写入时 format |
|
|
|
|
### 实测收益(302 文件合成项目,dry 模式,3 次取均值)
|
|
|
|
| 场景 | 基线 | 改动后 | 提升 |
|
|
|---|---|---|---|
|
|
| 全量构建(冷) | 6011 ms | 5629 ms | **-6.4%** |
|
|
| 增量构建(第 3 次,不清理 build) | — | 5023 ms | **-16.4%**(相对基线冷构建) |
|
|
|
|
说明:dry 模式不包含 C++ 编译,S2 对象缓存的更大收益(跳过 g++/cl 编译)需在完整 build 场景体现;dry 耗时中固定开销(CLI 启动、YAML、符号表)占比高,稀释了 S1 的解析节省。
|
|
|
|
### 验证记录
|
|
|
|
- `parseCachedAst`:同一文件 prepare+convert 各调用一次,第二次命中缓存且返回深拷贝(不共享节点、无 resolvedName 残留污染)✓
|
|
- `writeFile` 内容比对:相同内容跳过写入,mtime 保持不变 ✓
|
|
- `isGeneratedSourceFile`:buildDir 前缀 + .cc 扩展名判断,与 `getCppFile` 路径一致 ✓
|
|
- PHPUnit:Entity 目录 85 测试、FileScanner 等 110 测试通过(1 个 Windows 路径分隔符既有失败与本次无关)
|
|
- `typephp-think`(554 文件):补丁后 prepare 通过,convert 阶段因「仓库源码类型系统比 v1095 正式版严格」(`Cannot re-assign`)受阻,属源码版本差异,非本次改动问题
|
|
|
|
---
|
|
|
|
## 1. 编译是如何实现的(管线剖析)
|
|
|
|
### 1.1 总体流程
|
|
|
|
入口为 `bin/tpc.php` → `src/compiler.php` 的 `main()` → `src/Translator.php`(继承 `Preprocessor` → `CompilerBase`)。编译是一条四阶段流水线:
|
|
|
|
```
|
|
┌────────────────────────────────────────────────────────────────────────┐
|
|
│ ① prepare() 扫描+解析+建符号表+依赖拓扑排序 │
|
|
│ src/Build/SourcePipelineTrait.php::prepare() │
|
|
│ → 每文件: Preprocessor::prepareFile() │
|
|
│ parser->parse() ←── AST 解析 #1 │
|
|
│ NodeTraverser(NameResolver + Visitor + 常量校验 + AttributeLower) │
|
|
│ 收集 symbolDeclInFile / symbolCallInFile / classMap / funcMap │
|
|
│ → getSortedFiles(): marcj/topsort 按文件间符号依赖拓扑排序 │
|
|
├────────────────────────────────────────────────────────────────────────┤
|
|
│ ② convert() 每文件: AST → C++ 源码 │
|
|
│ src/Build/SourcePipelineTrait.php::convert() │
|
|
│ → Translator::convertFile() → doConvert() │
|
|
│ loadFile() 读盘(第二次) │
|
|
│ parser->parse() ←── AST 解析 #2(同一文件解析两次!) │
|
|
│ NodeTraverser 再次遍历(与 prepare 相同的 4 个 visitor) │
|
|
│ 逐节点翻译生成 C++(CompilerBase 4000 行核心逻辑) │
|
|
│ → 生成 func_decl.h / data_decl.h / extension-<target>.cc │
|
|
├────────────────────────────────────────────────────────────────────────┤
|
|
│ ③ compile() C++ → .o(外部编译器) │
|
|
│ Translator::compile() │
|
|
│ → phpx misc 文件 + 生成的 .cc │
|
|
│ → PCH 预编译头(PrecompiledHeaderManager,内容指纹缓存) │
|
|
│ → Linux/macOS: pcntl fork 并行(默认 4 jobs,大文件优先调度) │
|
|
│ → Windows: 串行(supportsPcntlParallelCompile() = false) │
|
|
│ → phpx misc 有 .o 缓存(hasMiscObjectFileCache,命令+ABI 指纹) │
|
|
├────────────────────────────────────────────────────────────────────────┤
|
|
│ ④ build() 链接为可执行文件 / 扩展 / 库 │
|
|
│ Translator::build() → NativeBuilder::link() │
|
|
└────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 1.2 关键实现位置
|
|
|
|
| 职责 | 文件 |
|
|
|---|---|
|
|
| 四阶段编排 | `src/Build/SourcePipelineTrait.php`(prepare/convert)、`src/Translator.php`(compile/build) |
|
|
| PHP→C++ 逐节点翻译 | `src/CompilerBase.php`(4133 行,核心) |
|
|
| 扫描/符号表/依赖排序 | `src/Preprocessor.php` |
|
|
| 编译器抽象 | `src/Backend/`(CompilerBackend → GccLikeBackend → Gcc/Clang、Msvc) |
|
|
| 平台抽象 | `src/Platform/`(Linux/Macos/Windows) |
|
|
| 并行编译调度 | `src/Build/NativeBuilder.php::dispatchParallel()` + `SourceCompileQueue` |
|
|
| PCH 管理 | `src/Build/PrecompiledHeaderManager.php` |
|
|
| 生成物落盘 | `CompilerBase::writeFile()`(无内容比对,直接覆盖) |
|
|
|
|
### 1.3 当前已具备的优化
|
|
|
|
1. **PCH 预编译头**:`phpx.h` 等 8 个公共头打成 `.gch`,带 24 位 sha256 内容指纹缓存(`PrecompiledHeaderManager`)。
|
|
2. **phpx misc 对象缓存**:`phpx/src/misc/*.cc` 的 `.o` 用「编译命令 + PHP ABI」指纹缓存,避免每次全量重编 PHPX 运行时。
|
|
3. **编译并行**:Linux/macOS 用 pcntl fork 并行(`-j`,默认 4),且按「大文件优先占满槽位 + 小文件快速通道」调度。
|
|
4. **编译期常量展开、字面量字符串表**、`--dry` 只生成 C++ 等。
|
|
|
|
---
|
|
|
|
## 2. 性能瓶颈实测
|
|
|
|
### 2.1 基准方法
|
|
|
|
用 `nikic/php-parser 5.6.1`(与仓库 `composer.json` 一致)对 `src/` 全部 139 个 PHP 文件(约 1.65 MB)做多轮测量:
|
|
|
|
```
|
|
parse (nikic/php-parser) ×3 1455.5 ms → 单遍 ≈ 485 ms(3.18 MB/s)
|
|
parse + NameResolver ×3 1797.0 ms → 单遍 ≈ 599 ms
|
|
parse+resolve+serialize (1x) 701.1 ms
|
|
unserialize ×3 779.8 ms → 单遍 ≈ 260 ms
|
|
parse+resolve+prettyPrint ×3 2196.0 ms
|
|
serialized AST 体积 47.6 MB(源码的 28.8 倍)
|
|
```
|
|
|
|
### 2.2 结论性瓶颈清单(按影响排序)
|
|
|
|
| # | 瓶颈 | 证据 | 影响 |
|
|
|---|---|---|---|
|
|
| B1 | **每个 PHP 文件被解析两次** | `prepareFile()`(Preprocessor.php:130)与 `doConvert()`(Translator.php:2325)各自 `parser->parse()` 一次,且各自跑一遍完整 NodeTraverser | PHP 侧最大浪费:139 文件规模下纯解析+resolve ≈ 2×599ms ≈ **1.2s 起步**,大项目线性放大 |
|
|
| B2 | **生成的 .cc 无增量缓存** | `compileFile()` 只对 phpx misc 走 `hasMiscObjectFileCache`;用户代码生成的 `.cc` 每次全量重编 | 改 1 个 PHP 文件也要重编全部 .cc(C++ 编译是耗时的绝对大头) |
|
|
| B3 | **生成文件无内容比对** | `CompilerBase::writeFile()` 直接 `file_put_contents` 覆盖,mtime 必然变化 | 即使内容相同也触发下游重新依赖判断/重编,破坏增量可能性 |
|
|
| B4 | **prepare/convert 阶段完全串行** | 两阶段都是单进程 `foreach` | 多核只用于 compile;PHP 侧解析/翻译占用的核数为 1 |
|
|
| B5 | **Windows 无并行编译** | `Windows::supportsPcntlParallelCompile() = false`,直接走 `compileSourceFile()` 串行 | Windows 大项目编译时间 = 串行全量 |
|
|
| B6 | **PCH/缓存指纹每次全量计算** | `PrecompiledHeaderManager::buildFingerprint()` 每次构建都递归遍历 phpx 头文件并逐个 hash(实测 131 个头文件 ≈ 42ms,PHP 侧还有 misc 缓存同样的遍历) | 每次构建的固定开销,虽不大但在累积 |
|
|
| B7 | 转换阶段反复 `getType()` 字符串比较、NodeFinder 多次全树扫描(如 `findSymbolUsing`、`canOptimizeMultiReturn`) | CompilerBase/Preprocessor 中多次独立 `findInstanceOf` | 每文件多次 O(N) 全树扫描,可合并为一次遍历 |
|
|
|
|
### 2.3 一个反直觉的事实
|
|
|
|
**AST 序列化缓存是划算的**:unserialize 单遍 260ms vs parse+resolve 599ms(快 2.3 倍),即使加上 serialize 成本(首轮 ~700ms/139 文件),二次构建纯 PHP 解析阶段也能从 ~1.2s 降到 ~260ms。但注意序列化体积膨胀 28.8 倍(47.6MB/1.65MB),对磁盘 I/O 有压力——更优做法是**合并两阶段解析**(见方案 S1)。
|
|
|
|
---
|
|
|
|
## 3. 提速方案(按性价比排序)
|
|
|
|
### S1(P0)prepare/convert 合并为单次解析 —— 省一半 PHP 解析时间
|
|
|
|
- **问题**:同一文件 `prepare` 和 `convert` 各 parse+traverse 一次,逻辑完全重复(visitor 列表一致)。
|
|
- **做法**:让 `prepareFile()` 把 parse 好的 `$stmts`(NameResolver 等已跑完)挂到文件缓存(内存 `array<string, array<Node>>`,或按 `filemtime+内容hash` 落盘 `build/cache/ast/`),`doConvert()` 优先取缓存;取不到(如 `--force`、跨进程)才重新 parse。
|
|
- **收益**:139 文件规模 PHP 解析从 ~1.2s → ~0.6s;大项目直接减半。
|
|
- **风险**:低。`convert` 依赖的 `resolvedName` 等 attribute 由 NameResolver 在 traverse 时写入,缓存的是 traverse 之后的 AST,语义不变。需注意 `ConstantExpressionValidationVisitor` 的报错回调在 prepare 是 warning、convert 是 fatal,缓存后错误在 prepare 阶段就能暴露,行为只会更早更一致。
|
|
|
|
### S2(P0)生成 .cc 的增量对象缓存 —— 编译期最大收益
|
|
|
|
- **做法**:把 `hasMiscObjectFileCache()` 的机制推广到所有生成的 `.cc`:缓存 key = 编译命令 + PHP ABI + **生成物内容 hash(或源 PHP hash + 编译器版本)**。内容未变 → 跳过 g++/clang。
|
|
- **配套**:`writeFile()` 加「内容比对,相同则不落盘」逻辑(解决 B3),保证 mtime 稳定。
|
|
- **收益**:改 1 个文件后二次构建,C++ 编译从全量变为仅差异文件;这是肉眼可感知的最大提升。
|
|
- **风险**:中。生成 .cc 的内容由 `genFunctionDeclarations`/`genDataDeclarations` 等公共头影响——若公共头变了,所有 .cc 的 `#include` 语义可能变,但 .cc 内容 hash 不会变。**必须把公共声明头也纳入 key**(hash func_decl.h/data_decl.h/extension 文件内容),或采用「.cc 内容未变 且 公共头未变 且 .o 存在 且 命令指纹未变」四条件跳过。
|
|
|
|
### S3(P0)修复 PCH/缓存指纹的计算时机
|
|
|
|
- `buildFingerprint()` 每次构建全量扫描+hash 头文件(~42ms + misc 缓存同款遍历),应改为「mtime+size 列表指纹」先行判断,内容 hash 仅在前者变化时执行。
|
|
|
|
### S4(P1)prepare/convert 并行化
|
|
|
|
- parse+resolve 是纯函数,可用 pcntl fork 分片解析(子进程只做 parse+serialize,父进程归并反序列化),类似已有 `dispatchParallel` 的模式。转换阶段因共享状态(literalStrings、classMap 等)风险高,可先只并行「解析」,翻译仍串行。
|
|
|
|
### S5(P1)Windows 并行编译
|
|
|
|
- 两条路:(a) 给 `Msvc` 后端加 `/MP`(MSVC 原生多进程编译,单命令即可);(b) Windows 上用 `proc_open` 实现多进程调度替代 pcntl。收益对 Windows 用户显著。
|
|
|
|
### S6(P2)工具链层
|
|
|
|
- `ccache`/`sccache` 兜底(对 C++ 编译做内容缓存,配合 S2 效果更稳);链接器换 `mold`/`lld`;`-pipe`、`-fno-asynchronous-unwind-tables` 等编译 flag 微调。
|
|
|
|
### S7(P2)单次遍历替代多次 NodeFinder
|
|
|
|
- `findSymbolUsing()` 等每文件多次 `findInstanceOf` 全树扫描,可合并为一次 visit 收集函数调用/类引用/常量引用三类符号,减少 O(N) 重复。
|
|
|
|
---
|
|
|
|
## 4. AST 解析能否用 mago 替代?—— 结论:不建议(现阶段不可行)
|
|
|
|
### 4.1 mago 是什么
|
|
|
|
[mago](https://github.com/carthage-software/mago)(carthage-software/mago)是一个 **Rust 编写的 PHP 工具链**:lint、format、静态分析(对标 Clippy/OXC),v1.45.0(2026-07),MIT/Apache-2.0 双许可,开发活跃。其核心是自研高性能 PHP 解析器(crate:`mago-syntax` 词法/语法/遍历、`mago-names` 名称解析),官方基准:7M 行 WordPress 静态分析 1.46s(PHPStan 55.9s)、lint 0.88s、format 0.43s。**已支持 PHP 8.4 property hooks、asymmetric visibility(private(set) 等)。**
|
|
|
|
### 4.2 为什么不能直接替代
|
|
|
|
| 维度 | nikic/php-parser(现状) | mago | 兼容性判断 |
|
|
|---|---|---|---|
|
|
| 语言/进程 | PHP 类库,进程内对象 | Rust 二进制/crate,AST 是 Rust 结构 | 无法在 PHP 进程中直接消费 |
|
|
| AST 模型 | AST(抽象语法树),含 attribute 机制(`resolvedName`/`namespacedName` 等存于节点属性) | CST(具体语法树,含 trivia),`mago ast --json` 是调试输出 | 结构不同,需要完整映射层 |
|
|
| 下游依赖 | TypePHP 全库 296 处 `PhpParser\*` 引用、75 个文件;依赖 `NodeTraverser`/`NodeVisitor`/`NameResolver`/`NodeFinder`/`ConstExprEvaluator`/`PrettyPrinter`/`Modifiers` 等 | 无 PHP API | 替换 = 重写编译器前端 |
|
|
| 名称解析 | `NameResolver` 写入节点 attribute,后续代码生成大量读取 | `mago-names` 输出到 Rust 侧 | 需要桥接层搬运 |
|
|
| 代码生成 | `PrettyPrinter` 用于 stub 生成(`LibraryImportStubGenerator`、`gen_stub.php`) | 无等价物 | 缺失 |
|
|
| 错误语义 | `PhpParser\Error` + 行列号,被 `SyntaxError` 包装 | 错误模型不同 | 需要适配 |
|
|
|
|
**核心矛盾**:TypePHP 不是「解析完就丢」的 linter,而是「AST 必须留在 PHP 进程内、被 4000 行 CompilerBase 逐节点翻译」的编译器。走 mago 只有两条路,都不可行:
|
|
|
|
1. **mago CLI → JSON AST → PHP 重建对象**:JSON 序列化体积远大于源码(参考第 2 节:php-parser AST 序列化已膨胀 28.8 倍,mago CST 只多不少),反序列化 + 对象重建的 CPU/内存开销会**吃掉**解析省下的时间,且每文件一次进程启动(exec)成本极高;同时丢失 attribute 机制、注释/行列语义需重新映射。
|
|
2. **PHP FFI 绑定 mago-syntax crate**:需要为整个 Rust AST 写 FFI 边界 + 转 PHP 对象层,工程量接近重写前端,且 mago 的 CST 与 TypePHP 需要的「带 resolvedName 的 AST」不是一回事,仍要在 PHP 侧自建名称解析与代码生成数据模型。
|
|
|
|
### 4.3 mago 在本项目中的合理定位(可选)
|
|
|
|
- **前置快速语法校验**:编译前用 `mago`(或 `mago lint --quick`)做廉价的全项目语法/明显错误预检,给出比 php-parser 更快的反馈(适合 IDE/CI 场景,不进入编译流水线核心)。
|
|
- **性能参照物**:mago 证明了「Rust 原生解析」的量级(~百 MB/s vs php-parser 3 MB/s),可作为未来「把前端重写为原生编译器(如 Rust 侧做 parse+name-resolution,产物是自研序列化 AST)」的路线参考——但那是一个全新项目,而非对现有编译器的增量改造。
|
|
- **若仍想引入原生解析**:更现实的是保留 php-parser 语义、仅加速解析环节(如 `--php-parser` 的 `PhpParser\ParserFactory` 换用 tree-sitter 绑定并在 PHP 侧重建 php-parser 节点),但收益-成本比仍不如 S1/S2。
|
|
|
|
### 4.4 结论
|
|
|
|
> **不建议用 mago 替换 nikic/php-parser**。mago 是「解析即弃」的工具链,TypePHP 是「解析后深度消费 AST」的编译器,二者 AST 模型与进程模型不兼容,桥接成本会抹平解析速度优势。
|
|
> 真正的提速路径在 **S1(两阶段合并单次解析)+ S2(生成 .cc 增量缓存 + writeFile 内容比对)+ S4/S5(并行化)**,全部基于现有架构增量改造,风险可控、收益立竿见影。
|
|
|
|
---
|
|
|
|
## 5. 落地顺序建议
|
|
|
|
```
|
|
第一阶段(P0,改动小收益大,1-2 天)
|
|
1. writeFile 内容比对(S2 前置)
|
|
2. prepare/convert 共享 AST(S1)
|
|
3. 生成 .cc 增量对象缓存(S2,key 含公共头内容 hash)
|
|
第二阶段(P1,结构性收益)
|
|
4. PCH/misc 指纹轻量化(S3)
|
|
5. 解析阶段 pcntl 并行(S4 解析部分)
|
|
6. Windows 并行(/MP 或 proc_open)(S5)
|
|
第三阶段(P2,长期)
|
|
7. 单次遍历合并 NodeFinder(S7)
|
|
8. ccache / mold / lld(S6)
|
|
```
|
|
|
|
## 6. 附:基准脚本
|
|
|
|
分析所用基准脚本:`.workbuddy/bench_ast.php`(可重复运行:`php .workbuddy/bench_ast.php`),复现本节所有数字。
|
|
|