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.
 
 

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.phpsrc/compiler.phpmain()src/Translator.php(继承 PreprocessorCompilerBase)。编译是一条四阶段流水线:

┌────────────────────────────────────────────────────────────────────────┐
│ ① 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 多次全树扫描(如 findSymbolUsingcanOptimizeMultiReturn 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 解析时间

  • 问题:同一文件 prepareconvert 各 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(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 生成(LibraryImportStubGeneratorgen_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-parserPhpParser\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),复现本节所有数字。