Micronaut Python 编译器基准剖析与生成代码瘦身:PyronautCompiler 性能基线、Hook 清理与可复现 profiling 实践
Micronaut Python 编译器基准剖析与生成代码瘦身:PyronautCompiler 性能基线、Hook 清理与可复现 profiling 实践
导读
本文以 Micronaut 仓库内 test-suite-python/PYTHON_COMPILER_BENCHMARK.md 为核心,深入剖析 Micronaut Python 编译器(PyronautCompiler)如何通过内置 profiling 能力建立可复现的编译性能基线,以及作为压缩 Python 编译耗时计划第一步的"属性 hook 清理"(property hook cleanup)。你将掌握编译器各阶段耗时分布、官方测量方法(builder API 与报告文件格式)、清理前后的量化对比,以及从源码中印证这些结论的路径,从而在自己的项目中复现测量、理解生成代码体积与编译耗时的关系。
一、背景:为什么要建立 Python 编译基线
Micronaut 5.2.x 支持以 Python 编写应用(inject-python、context-python 等模块),Python 源码会被编译进生成的 Java 桩(stub)类中。开发者对 Python 编译耗时的优化计划,首先需要一个用可复现工具测得的、修正后的基线,而不是零散的直觉性测量。本文档对应的分支 claude/python-compiler-baseline-ef4sxf 基于已合入 5.2.x 的 PR #13250,其工作包含三部分:
- 编译器自带 profiling 能力:
PyronautCompiler.Builder新增profileReportFile(File)与profileCallback(Consumer<CompilationProfile>),记录各阶段耗时、javac 轮次、Python 模型计数与输出清单。 - Hook 清理:生成的类仅在方法体比接口默认实现做更多事时,才声明
micronautValueCoercibleSetMember/micronautValueCoerciblePutMember,删除大量"仅return false"的平凡覆写。 - 端到端测试:覆盖混合 Python/Java 包、以及"编译期存在、运行期缺失"的类值注解场景(#13250 的修复内容)。
优化计划从修正基线起步,hook 清理只是其中第一步、仅涉及代码体积的步骤。
二、内置 profiling:PyronautCompiler 的编译画像能力
2.1 开启方式与 API
从 PyronautCompiler.java 的源码可见,profiling 由两个 Builder 方法启用(对应文档中的 profileReportFile(File) 与 profileCallback(Consumer)):
profileReportFile(File):每次编译向该文件追加一个key=value行块,包含 attributes、phase.<name>.ms、counter.<name>、artifact.<kind>.count与.bytes;profileCallback(Consumer):把完整的CompilationProfile对象交给调用方。
只有二者之一被设置时,profiler 才会被创建(createProfiler() 中 profileReportFile == null && profileCallback == null 时返回 null),因此默认零开销。在 CompilationProfileTest 中可以看到完整的驱动方式:
PyronautCompiler.Builder builder = PyronautCompiler.builder()
.pythonSrc(sources.toString())
.targetDir(output.toFile())
.profileCallback(profiles::add); // 或 .profileReportFile(report.toFile())
builder.build().compile();
2.2 报告格式(key=value 块)
CompilationProfile.java 的 render() 定义了输出格式:先写 attributes,再按启动顺序输出各阶段 phase.<name>.ms(阶段运行多次时附 phase.<name>.count),随后是 counter.<name> 计数,最后是输出清单 artifact.<kind>.count / artifact.<kind>.bytes。文件以 # Pyronaut compilation profile 开头,每次编译追加一个块,因此同一个报告文件里多份编译可以直接比较、无需解析器。
关键 attribute 包括:
| Attribute | 含义 |
|---|---|
jvm / jvm.pid / jvm.max-heap.mb |
JVM 名称、进程号与最大堆 |
jvm.uptime.ms |
JVM 已运行毫秒数,用于区分全新 worker 与复用后热身的 worker |
target |
输出目录(内存编译时为 memory) |
incremental / session |
是否增量编译、是否复用 PythonProcessingSession |
total.ms |
整个编译总耗时 |
阶段名称采用"所属组件.阶段名"的点分约定(如 javac.task、python.transform),并记录嵌套深度与调用次数,便于还原调用结构。
2.3 底层实现:CompilationProfiler
CompilationProfiler.java 是采集核心:
phase(String)返回一个Span(AutoCloseable),关闭时把含嵌套的完整耗时累加进该阶段(文档中的"inclusive phase times");阶段嵌套深度由运行栈大小决定。increment(...)累加计数器;inventory(Path)在编译写出后遍历输出目录,按扩展名归类为java-source、class、python-source、python-bytecode、resource,统计数量与字节数。- 构造时记录 JVM 启动时刻、进程 PID 等,正是文档中用于识别"全新 worker"的
jvm.uptime.ms。 - 方法均加锁(虽然实际只在单线程调用),防止其他线程的回调破坏数据。
在 PyronautCompiler.java 中,compiler.compile 与 compiler.build-class-loader 两个总阶段分别包裹 compileToDisk 与内存编译;finishProfile 还会先执行 compiler.inventory 阶段再做输出盘点。值得注意的细节:当编译失败时,profiling 自身的失败(如报告文件写入失败)会作为 suppressed 异常附加,绝不掩盖真正的编译错误(对应 CompilationProfileTest.reportsTheCompilationFailureWhenTheProfileCannotBeWritten)。
2.4 报告文件的并发安全
由于 Gradle 会并行启动多个项目的 Python 编译任务、每个任务一个独立 worker 进程,多个进程可能同时写同一报告路径。appendProfileReport 采用"JVM 内对象锁 + 进程间文件锁"双重机制,以整块方式追加(channel.lock() 覆盖写操作),保证块永不交错。CompilationProfileTest.appendsWholeBlocksWhenCompilationsShareAReportFile 用 3 个并发线程共享同一报告文件验证了该行为。
三、修正后的基线:test-suite-python 编译画像
3.1 测量环境与方法
文档对基线测量的条件做了严格声明,这是"可复现"的关键:
- 负载为
:test-suite-python:compileTestPython,即编译 test-suite-python 的 426 个 Python 输入; - Temurin 25.0.4.1,Linux x64,4 CPU,Gradle worker 默认 2 GB 堆;
- GraalPy 25.3.4.1 运行在 Truffle fallback 解释器上(Python 无 JIT);
- 每次编译都在全新的 worker JVM 中进行:22 次编译的 JVM 启动时间均为 0.5–0.6 s,因此不涉及复用热 worker,编译器自身的
PythonProcessingSession复用也未在 Gradle 任务中被触发; - 两版本负载、修订顺序、JVM 完全一致,且修订交替运行以抵消机器噪声;
- Python 阶段在 javac 任务内部运行(注解处理器在第一轮完成 Python 工作)。
从 test-suite-python/build.gradle.kts 可以看到 compileTestPython 任务确实来自共享的 io.micronaut.build.python.PythonCompile 插件,并给测试任务注入了 micronaut.python.pool.enabled=false 等系统属性。
3.2 阶段耗时分布(基线的 11 次编译中位数)
| 阶段 | 中位数 | 范围 |
|---|---|---|
Compilation(compiler.compile) |
34.3 s | 32.7–35.1 s |
| javac 任务全部轮次 | 33.9 s | 32.3–34.7 s |
| GraalPy 上下文创建 | 2.7 s | 2.6–2.8 s |
AST 变换(micronaut_transformer,426 个源) |
13.2 s | 12.5–13.8 s |
模型构建(micronaut_processor) |
4.9 s | 4.6–5.1 s |
| Python 源码与字节码写入 VFS | 1.5 s | 1.1–1.6 s |
| 聚合型访问器 | 0.4 s | 0.4–0.5 s |
| 隔离型访问器(Java 桩生成) | 3.4 s | 3.0–3.5 s |
| Bean 定义 | 2.0 s | 1.6–2.1 s |
| javac 任务减去 Python 阶段(解析与编译生成源码) | 5.9 s | 5.7–6.2 s |
解读:变换 + 模型合计 18.1 s,占 34.3 s 的过半。而 javac 处理生成源码的轮次还需约 5.9 s。
3.3 每轮编译都一致的计数
| 计数项 | 数值 |
|---|---|
| Python 输入 | 426 |
| 模型中的 Python 类 | 456 |
| javac 轮次 | 5 |
| javac 分析的编译单元 | 787 |
| 变换器渲染的装饰器条目(每源每导入注解各一) | 3,325 |
| 其背后的唯一注解 | 137 |
| 渲染的装饰器源码字符数 | 4,328,240 |
| 生成的 Java 源码 | 786(2,013,911 字节) |
| 类文件 | 1,828(8,973,417 字节) |
| VFS 中的 Python 源码 | 675 |
| Python 字节码文件 | 357 |
| 其他资源 | 1,100 |
3,325 个装饰器条目对应 137 个唯一注解、渲染出 430 万字符的源码,这正是优化计划第 3 步的证据:大量重复渲染同一注解的装饰器体。
四、Hook 清理:针对基线的实测对比
4.1 清理内容
生成的 Java 桩类会覆写一组 ValueCoercible 属性成员方法(getter/setter 名称表 + set/put 成员)。此前无论类是否有属性字段,都会生成 micronautValueCoercibleSetMember / micronautValueCoerciblePutMember 覆写;清理后仅当方法体比接口默认实现做得更多时才声明它们。
对应源码在 PythonStubGenerator.java:
inheritsPropertyMemberHooks(ClassStubModel)判断生成的类是否从基类继承会写字段的 hook:仅当父类为 Python 基类且带@Introspected、有 bean 属性字段时才继承;当父类是编译桩或其他 Java 基类(其 hook 体未知)时保留抑制性覆写;若有效实现是接口默认实现(无基类、Object/接口基类、或无字段的 Python 基类),则不声明覆写。addValueCoerciblePropertyMembers中,micronautValueCoercibleGetterPropertyName/micronautValueCoercibleSetterPropertyName(名称映射表)始终生成,而 set/put 两个成员方法仅在overridePropertyMemberHooks为真时生成,避免产出"与默认return false相同"的冗余代码。
4.2 量化结果
在 426 个 Python 输入、786 个生成 Java 源、1,828 个类文件规模下,清理移除了 786 个生成源中 364 个平凡覆写:
- 方法声明减少 40.4 KB;生成 Java 总量 2,013,911 → 1,972,903 字节(-2.0%);
- 类文件 8,973,417 → 8,936,451 字节(-0.4%);
- 生成源数量(786)与类文件数量(1,828)不变;
- Hook 覆写数:基线 442 个(其中 364 个是
return false)→ 候选版本 78 个(无一平凡)。
4.3 耗时:不变即预期
| 指标 | 基线(n=11) | 候选(n=11) |
|---|---|---|
| 编译中位数 [min-max] | 34.3 s [32.7–35.1] | 34.3 s [32.5–34.9] |
| javac 任务 | 33.9 s [32.3–34.7] | 33.9 s [32.1–34.5] |
| AST 变换 | 13.2 s [12.5–13.8] | 13.0 s [12.7–13.6] |
| 模型 | 4.9 s [4.6–5.1] | 4.8 s [4.6–5.0] |
| 隔离型访问器 | 3.4 s [3.0–3.5] | 3.2 s [3.1–3.4] |
| Bean 定义 | 2.0 s [1.6–2.1] | 2.1 s [1.6–2.2] |
| javac 任务减去 Python 阶段 | 5.9 s [5.7–6.2] | 6.0 s [5.5–6.3] |
按执行顺序排列的 11 次编译耗时(s):基线 34.8 / 34.3 / 34.2 / 32.7 / 33.9 / 33.4 / 34.9 / 33.9 / 35.0 / 35.1 / 35.0;候选 34.1 / 34.4 / 34.3 / 32.5 / 33.8 / 34.8 / 34.2 / 34.3 / 34.9 / 34.3 / 34.4。
差异都在各自的波动范围内,编译耗时不变(中位数 34.3 s)。文档对此的定性非常克制:这是代码体积清理的预期结果,报告为体积缩减,而非速度提升。
Gradle 构建墙钟时间也基本持平:单次 daemon 51.8 s [51.2–58.8] vs 51.4 s [51.2–57.8];持久 daemon 的后续构建 39.3 s [38.3–40.2] vs 38.7 s [38.2–39.2]。
分支后来 rebase 到 #13250 更新后的头(改动清单写入与运行时模块、不影响编译阶段),五次全新 worker 的短确认跑同样结论:基线 36.0 s [34.5–36.7] vs 候选 35.7 s [34.8–36.7](机器更忙、两者都略慢),且清单字节完全一致(2,013,911 → 1,972,903 字节生成 Java、8,973,417 → 8,936,451 字节类文件、442 → 78 个 hook 覆写)。
五、计划起点的三个回归修复与端到端验证
优化计划之前复现的三个回归,均由已合入的 #13250 修复:
- 同名混合包的星号导入:应用包与 Java 包同名(如
micronaut.context与io.micronaut.context)时,星号导入不再只绑定 Python 成员,Java 成员也正确导出; - 经混合包导入 Java 类型的模块:
import micronaut.http.HttpResponse这类"类型模块导入"不应把该类型在模块上的绑定替换成模块本身; - 运行期缺失注解的类值参数:注解值持有
Class<?>、且注解类型只在编译类路径上(运行期类加载器不可见)时,作为裸应用启动不再失败。
单测位于 JavaImportFinderSpec 与 CompilerSilentFailureSpec;端到端场景由 MixedPackageJavaImportsSpec.groovy 覆盖:一个注解被编译进运行期类加载器不可见的目录、在模块导入时以类参数应用,同时混合包的星号导入、__all__ 顺序、dir() 与类型模块导入都在运行期被断言。该测试断言(摘录):
- 星号导入后
namespace['ApplicationContext'].__name__等 Java 成员可解析; __all__中 Python 成员在 Java 成员之前(all_python_first == "True"),dir()同时列出两者;micronaut.http.HttpResponse is HttpResponseType is HttpResponse,即类型模块导入保留类绑定;- 类值注解
ClassValued(Marker)(Marked)在类型缺席运行期时仍可用(class_valued_absent == "ok"),且ClassValued.java_class_name == "compileonly.ClassValued"。
六、复现测量:如何在自己的环境里做
6.1 通过 Builder API(当前仓库内)
在 inject-python 模块下,直接使用 builder API 即可复现,无需任何 Gradle 属性转发:
PyronautCompiler compiler = PyronautCompiler.builder()
.pythonSrc("/path/to/python/sources")
.javaSrc("/path/to/java/sources") // 可选
.targetDir("/path/to/output")
.classpath(List.of(...)) // 编译类路径
.profileReportFile(Path.of("/tmp/profile.txt").toFile()) // 或 .profileCallback(...)
.build();
compiler.compile();
profileReportFile每次编译追加一个块;多次编译共享文件时可跨修订比较;profileCallback把CompilationProfile直接交给代码(phase(name)、artifact(kind)、counters()均可查);- 内存编译(
buildClassLoader())同样支持 profiling,此时targetattribute 为memory、无输出清单(对应CompilationProfileTest.profilesAnInMemoryCompilation); - 用报告里的
jvm.uptime.ms判断一次编译是否发生在全新 worker 中。
6.2 通过 Gradle 任务(属性转发已外移)
文档中的测量是对 :test-suite-python:compileTestPython 打 profile 得到:编译任务的 worker 在启动时被传入报告路径,转发属性为 -Dmicronaut.python.compiler.profile=<file>。该 Gradle 插件此后已移出本仓库、进入 micronaut-build 共享的 io.micronaut.build.internal.python 插件(#13198),因此从 Gradle 构建转发该属性属于后续跟进项;从 builder API 做 profiling 不受影响。
6.3 验证命令
文档声明的验证(-Ppython-ci 开关下)包括:
:micronaut-inject-python:test(130 个测试):micronaut-inject-python-test:test(913 个):micronaut-context-python:test(198 个,2 个跳过):test-suite-python:test(224 个,opt-in 基准测试被跳过)- checkstyle 通过;
:micronaut-context-python-netty:test除两个 IPv6 测试外通过——这两个测试在无 IPv6 的沙箱里以同样方式失败(基线版本亦然)。
七、后续计划:压缩 Python 编译耗时的下一步
文档末尾给出优化计划路线图:
- Step 3a:每个编译单元只描述一次导入的注解,替代为 137 个唯一注解渲染 3,325 个装饰器体(430 万字符);
- Step 3b:把变换后的 AST 直接交给模型访问器,避免"反序列化字符串再重新解析";
- Step 4:整合 312 个目标类型适配器源码与 bean 定义。
这些步骤直接对应基线中最重的部分(AST 变换 13.2 s、模型 4.9 s、隔离型访问器 3.4 s、Bean 定义 2.0 s),也是读者在阅读本文后可以跟踪后续提交的关键方向。
八、要点总结
- 可复现基线优先:在评估任何编译优化前,先以全新 worker、交替修订顺序、统一 JVM/堆配置建立基线,并记录
jvm.uptime.ms等环境属性。 - profiling 零成本:
PyronautCompiler仅在显式配置profileReportFile/profileCallback时采集数据,报告采用追加式key=value块,跨编译、跨修订可直接 diff。 - 生成代码体积 ≠ 编译耗时:移除 364 个平凡 hook 覆写(生成 Java -2.0%、类文件 -0.4%)后编译耗时完全不变,说明耗时瓶颈在 Python 变换/模型阶段,而非生成源码的 javac 轮次。
- 证据链完整:测量方法、阶段耗时、计数、对比表、复现步骤与后续计划在文档中自成闭环,并可在 CompilationProfileTest.java、CompilationProfile.java、PythonStubGenerator.java 与 MixedPackageJavaImportsSpec.groovy 中逐条印证。