跳到主要内容

性能优化:IRify 第二轮全路径性能重构(SSA/SyntaxFlow/ANTLR)

· 阅读需 20 分钟
Yak Project
网络安全垂直语言团队

过去几个月,Yaklang 在 SSA、CodeScan、SyntaxFlow / SFVM、ANTLR / front-end 这几条线,做完了第二轮比较成体系的性能优化。

如果只看提交记录,会觉得这是一串零散的修复、重构和实验;但把这些工作放在一起看,会发现它们其实围绕的是同一个目标:

让 IRify 真正能够在大项目上更稳定地编译、更高效地扫描、更可控地执行规则,并且让后续第三轮、第四轮优化有清晰的落点。

先说几个最直观的结果。

  • 在真实目标 spring-cloud-netflix 上,这一轮 CodeScan 调优过程中的观测值,从 10.08s 降到了 5.59s
  • 在 SSA 指令搜索这条线上,sfvm.nativecall:getFormalParams:java-servlet-param 这一类核心热点,已经从早期的 6.065s 量级压到了“几十毫秒”量级。
  • 在 PHP mixed HTML 这个最典型、也最难看的前端热点上,BenchmarkFrontendPfsenseSystemInformationFixture15.36s/op 降到了 6.95s/op,时间下降约 54.74%;本地当前主线补跑时,结果也稳定在 7.82s/op 左右。
  • 在 Java 大型反编译目标的 AST-only 实验里,ANTLR 缓存清理参数调优前后,可以从 78.46s / 19.34GB 这类结果,压到 53.01s / 5.63GB 这一档;如果追求更激进的速度甜品点,也已经能跑到 51s 左右。
  • 在 Java 反编译大项目目标上,前端 AST 错误和 panic 已经被清到 0,系统瓶颈开始从“前端根本过不去”后移到“落库和持久化阶段怎么继续压”。

这几组数据放在一起,其实已经能说明很多问题:

第一,当前这一轮优化,已经不是“体感更快一点”的级别,而是真正把一批秒级、十秒级、甚至十几 GB 内存级别的问题打下来了。

第二,这一轮优化的重点,不只是提速,还包括把热点找准、把路径理顺、把默认参数选稳。

第三,现在已经落地的是第二轮结果,接下来会继续进入第三轮、第四轮。

这轮已经落地的内容,可以先概括成四件事:

1、SSA 编译和搜索路径更统一了,相关命令和文档也同步整理到了 ssa.to/docs

2、SSA 指令搜索开始从字符串匹配路径,推进到“常量池 + ID 路径”的结构优化。

3、SyntaxFlow / SFVM 的判断语句运行逻辑被重构了一轮,过去在大量值场景下容易拖很久的判断路径,被真正收住了。

4、ANTLR / front-end 不只是继续修语法边界,还开始通过参数 sweep、统一目标实验和 TokenSource 优化,系统性地压时间和内存。

与此同时,这并不是终点。

当前仓库里的 worktree 已经能明显看出后续方向还在继续推进,对应的其实就是下一阶段要继续啃的几块硬骨头:

  • 编译期内存和数据存储继续压缩
  • 时序和诊断能力继续补强
  • lazy build 路径继续推进
  • 数据流分析能力继续细化

所以,如果用一句话概括这篇文章想讲的内容,那就是:

这一轮 IRify 性能优化,已经把“能不能跑”推进到了“怎么跑得更快、更稳、更省”,而后面的第三轮、第四轮,也已经开始排上日程。

下面就按几个最关键的位置,分别讲清楚这轮到底优化了什么、为什么要这么做、以及它现在带来了什么结果。

SSA指令搜索的常量池优化

这一节对应的核心合并 PR 主要是:#4019#4040#4066#4092

之前的情况

这一轮之前,IRify 在 SSA 指令搜索上的核心能力已经是可用的,问题主要集中在“越大项目越容易把搜索路径拖慢”。

真正慢下来的位置,主要不在某一条规则本身,而在搜索链路背后的数据形态:

  • 指令、变量、成员、对象 key 这些名字,在持久化时会被抹平成数据库里的字符串字段
  • irindex 这类索引表里,name 字段存在大量重复
  • 一旦大项目里“很多值都叫同一个名字”或者“重名值特别多”,搜索就会在大量重复名字上反复做工作

也就是说,旧路径的问题不是“有没有搜索能力”,而是搜索时还带着太多字符串级别的重复劳动。

问题

这轮最开始最容易误判的地方,就是把热点简单理解成“正则匹配慢”。

实际上,真正的问题在更底层:

1、数据库里保留下来的是“名字到值”的映射,但重名很多。

2、一旦用字符串去搜,数据库会不断在重复名字上做重复匹配。

3、SQLite 的回调式正则在这种高重复场景下会进一步放大成本。

所以根因不是一句“正则慢”能解释的。

更准确地说,是:

同名值太多,字符串搜索路径又太重,导致数据库不断在重复名字上做重复工作。

这也是为什么这一轮最终会走到“常量池优化”。

这里说的常量池,实际落点就是 namepool 这条路径:先把名字集中管理,再尽量把搜索从字符串匹配推进到 id 路径。

方案

这条线真正的突破,不是直接大改,而是先做实验。

先做的几件事非常关键:

  • 在同一个真实目标 spring-cloud-netflix 上,反复跑 scan-only 基线
  • 把热点拆到具体执行点
  • 尝试把搜索数据 load 到内存里,验证是否能绕开 SQLite 的回调式正则
  • 进一步分析 irindexname 的重复度

实验做下来以后,方向就清楚了:

1、先从内存里把重复名字去重。

2、再对去重后的名字集合做匹配。

3、匹配完成后,再反查对应值。

4、再进一步,把字符串匹配路径推进到 id 路径,也就是常量池 / 名称池。

最终落到代码里的几个关键位置是:

  • common/yak/ssa/ssadb/name_cache.go
  • common/yak/ssa/database_search.go
  • common/yak/ssaapi/sf_search.go
  • common/yak/ssaapi/sf_native_call.go

具体改动包括:

  • memory mode 补上纯内存常量池
  • 搜索从 pattern -> string 改成 pattern -> NameCache IDs -> value IDs
  • SearchWithValue 加上 lazy / DB fast path
  • NativeCall_GetFormalParams 改成尽量不 materialize 完整函数对象

现在的情况和重构结果

现在这条链路最重要的变化,不是“加了个缓存”,而是搜索模型已经换掉了:

  • 不再优先做大范围字符串匹配
  • 开始优先走常量池 / namepool
  • 先去重,再匹配,再按 id 反查
  • memory mode 和 DB mode 的搜索模型开始变得更接近

这也是为什么这一节应该叫“常量池优化”,而不是简单叫 namepool

因为它背后解决的是一个更大的问题:

让搜索从“字符串不断重复扫”变成“名字集中管理、ID 路径优先”。

运行效率对比

这一节已经有几组很能说明问题的数据。

spring-cloud-netflix 的 scan-only 基线里,早期的核心热点包括:

热点早期耗时
sfvm.nativecall:getFormalParams:java-servlet-param~6.065s
sf.SearchWithValue:search-glob:*alibaba*fastjson~3.416s
sf.SearchWithValue:search-regexp:org.apache.logging.log4j~3.324s

继续优化以后,这条线最亮眼的结果之一是:

  • sfvm.nativecall:getFormalParams:java-servlet-param

从秒级,压到了“几十毫秒”量级。

这基本就是两位数量级的下降。

同时,从整体观测值看,在同一个真实目标的调优过程中:

观测项调优前调优后
spring-cloud-netflix 观测值10.08s5.59s

这组整体数据不能简单说成“全靠常量池快了这么多”,因为中间还叠加了:

  • scan-only 路径的使用
  • 空规则噪音清理
  • 其他热点修复

但它已经足够说明,SSA 指令搜索这条线的结构优化,已经开始实打实地把大项目扫描往下压了。

SyntaxFlow 判断语句 运行逻辑重构

这一节对应的核心合并 PR 主要是:#4108#4143#4140

之前的情况

SyntaxFlow 的过滤语法本身一直就很强,像这些写法大家都很熟:

  • a?{.b}
  • a?{.*<len>==2}
  • a?(*<len>==3)

从语法上看,它们都很好理解:

  • a?{.b}:判断每个 a 是否存在 .b
  • a?{.*<len>==2}:把 a 的成员展开后,判断这个 a 的成员数量是不是 2
  • a?(*<len>==3):先展开,再在条件里对展开后的结果做判断

通过这些条件对?之前的a进行过滤。

ssa.to 的 SyntaxFlow 文档里,?{...} 一直都是按“每个输入值在自己的上下文里求值”来解释的。

但在运行时实现层面,这件事过去并没有被完全收得这么干净。

问题

这轮之前,真正的问题不是“语法表达不了”,而是运行逻辑上有几层历史包袱:

  • Values 和旧的列表语义混在一起
  • 条件判断、值分组、锚点回投之间边界不够清楚
  • condition 判断路径使用的是循环式处理思路

这在简单规则里不一定明显,但一旦遇到:

  • 输入值很多
  • 条件嵌套很多
  • 中间又做了 * 展开、<len><slice> 这类操作

就会出现一个很难受的现象:

规则不是不能跑,而是 condition 判断会沿着旧路径对值一个个循环判断,值越多,时间越长,几乎和值数量线性相关,所以一旦进入大量值场景就会非常慢。

所以这里的问题,不是“判断逻辑不正确”,而是:

判断逻辑在大量值场景下,执行路径不够稳定。

方案

这轮重构做的,是把这条判断路径整条收掉重来。

关键变化主要落在:

  • common/syntaxflow/sfvm/frame.go
  • common/syntaxflow/sfvm/condition_exec.go
  • common/syntaxflow/sfvm/native_call.go
  • common/syntaxflow/sfvm/values.go
  • common/syntaxflow/docs/sfvm-values-condition.md

核心思路有四个:

  1. Values 明确统一运行时值容器。
  2. 把条件判断统一收回 anchor-scope
  3. NativeCall 的 grouped 行为也交回 SFVM 自己处理。
  4. 把旧的 ValueList 和旧循环路径换掉。

在语义上,这轮重构之后,?{...} 的执行逻辑终于和 SyntaxFlow 文档里说的那一套真正对齐了:

  • a?{.b} 不再走“把所有值拉出来逐个循环判断”的旧路径,而是一起执行对每个输入 a 单独判断它自己有没有 .b
  • a?{.*<len>==2} 也不是把所有结果混在一起算,而是先标记前序,然后一起执行判断,再把判断结果映射回每个原始输入 a

同样地:

  • a?{.*<len>==2}

也不再是把所有值全拍平后算一个总 len,而是:

  • 先展开
  • 再按原始输入 a 回投
  • 最后决定哪个 a 应该保留

这也是为什么这轮方案的核心,不只是“换几个函数”,而是把:

  • Values
  • Condition
  • Anchor
  • NativeCall

这一整组关系重新理顺。

现在的情况和重构结果

现在 SyntaxFlow 判断语句这条线,最重要的变化是:

  • 判断语义和文档描述终于更一致了
  • 条件判断路径更统一了
  • 过去在大量值场景下容易拖很久的旧循环路径,被真正收住了

这件事在当前仓库测试里甚至有一个很直白的信号:

  • filter condition without iter loop

这个测试名本身就已经把问题说透了。

它不是在证明“能跑”,而是在证明:

这条条件过滤路径,现在已经不是靠旧的 iter-loop 逻辑在绕了。

运行效率对比

它的收益是“把长尾和坏路径收掉”。

  • 之前:大量值进入条件判断后,容易一直循环判断,时间非常长
  • 现在:这类判断路径和普通检索一样所有值一起执行,已经能稳定完成并且不需要任何循环操作。

也就是说,这一轮的收益主要体现在:

  • 把不可接受的长尾收掉
  • 把规则判断从“复杂时容易拖垮”变成“复杂时也能稳定执行”

这也是为什么这一节更像“运行逻辑重构”,而不是“一个单点提速 PR”。

ANTLR缓存清理机制

这一节对应的核心合并 PR 主要是:#4139

之前的情况

ANTLR 这条线的一个老问题是内存和时间,在之前的优化中我们先后完成了两个处理:

  • 最开始我们发现内存不会被清理,然后清理出来不使用全局缓存而项目内缓存,以得到更稳定的缓存控制清理。
  • 项目并发使用项目单一缓存将会带来锁的问题,导致并发执行非常慢,我们拆分出工作者协程并发,每个维护自己的缓存。

然后目前运行发现这带来了新的问题:runtime cache 会随着大项目不断膨胀,导致GC运行占用大量的cpu时间。

最关键的两个东西是:

  • DFA
  • PredictionContextCache

如果完全不管它们,在大目标上会越来越占内存;

但如果清得太勤,缓存来不及复用,解析的时间占用大部分导致整个的编译效率仍然会低。

所以这不是一个“开或关”的问题,而是一个需要实验判断的问题,我们需要在清理问题上找到一个指标并且寻找一个“甜点值”的配置作为默认。

问题

这条线真正要解决的是:

到底应该在什么时候清缓存,才能既不把内存推得太高,也不把性能打烂?

这件事必须同时看两组指标:

  • 时间
  • 峰值内存

因为只看其中一个,很容易选出一个看起来很好、但整体很差的策略。

方案

这轮的方案,不是拍脑袋改默认值,而是把缓存清理参数放出来,然后围绕统一目标持续做实验,比较不同参数下的时间和内存,再从结果里找一个真正合适的“甜品点”。

关键位置在:

  • common/yak/ssaapi/ssa_compile_utils.go
  • common/yak/java/tests/ast_parse_metrics_local_test.go

最核心的环境变量是:

  • YAK_ANTLR_CACHE_RESET_FILES

它的含义很直接:

每个 worker 解析多少个文件以后,reset 一次 runtime cache。

接下来做的事情,就是围绕统一目标持续跑实验,比较不同 reset 周期下的时间和内存,去找那个真正合适的“甜品点”。

现在的情况和重构结果

这轮之后,ANTLR cache reset 已经不是一个模糊经验,而是一个有实验数据支撑的机制。

而且当前主线代码里,默认值已经落下来了:

  • YAK_ANTLR_CACHE_RESET_FILES=100

这个默认值不是拍脑袋写进去的,而是比较了一轮又一轮结果后,选出的更稳妥默认点。

运行效率对比

先看极端情况。

策略时间峰值内存结论
不 reset78.46s19.34GB内存大,GC 压力也大
每次 parse 后都 reset88.54s2.20GB内存很低,但明显变慢

再看按“文件数”做 reset 的实验。

YAK_ANTLR_CACHE_RESET_FILES时间峰值内存
10053.01s5.63GB
12551.02s6.41GB
14550.17s7.09GB
15051.95s7.01GB
25050.67s9.67GB

这张表很能说明问题:

  • 145/150 这一带,是速度上的“甜品点”
  • 100 这一带,更偏向“速度和内存都稳”

所以这一轮最后落下来的结论不是:

  • 某一个值绝对最优

而是:

  • 存在一段明确的甜品区间
  • 默认值应该选一个更稳、更容易接受的点

因此主线代码最终选择了:

  • 默认 100

这个值没有追求单次最快,但它把时间和内存都压在了一个比较稳的区间里。

ANTLR SLL模式和 TokenSource优化

这一节对应的核心合并 PR 主要是:#4165,Java 相关 fixup 可对应 #4164,而通用 SLL-first 路径则和 #4139 连在一起。

之前的情况

这一节其实可以分成两条线:

  • Java:主要问题是各种真实项目、尤其是反编译代码里的 AST 边界
  • PHP:除了语法边界,还有真正的前端性能热点

在更早的时候,各个 front-end 的 parse 行为也没这么统一。

SLL、LL、缓存复用、错误回退这些事情,缺少一个统一抽象。很多语言前端还是各自处理自己的 parse 细节,这意味着:

  • 有的地方先走 LL
  • 有的地方自己做回退
  • 有的地方缓存行为不一致

而这轮之后,一个很重要的基础变化就是:#4139SLL-first 正式收进了统一 helper。

也就是说,现在各 front-end 默认会先尝试更快、分配更低的 SLL 路径,只有在需要时才回退到 LL,这让“先快跑、失败再兜底”变成了统一默认行为。

问题

Java 这一侧的主要问题,是:

  • 真实反编译项目里会出现大量奇怪 AST 边界
  • 系统经常还没走到后面的性能阶段,就先在前端 AST 这里挂住

PHP 这一侧的问题更复杂:

  • 真实 pfsense 项目里有一批 parser 边界问题
  • 更麻烦的是 mixed HTML/PHP 大文件特别慢

真正把 PHP 这个问题拆开以后,会发现:

  • 不是 SSA build 慢
  • 不是 go test harness 慢
  • 也不是简单的 SLL->LL fallback 导致慢

真正的热点是:

  • ANTLR 在 mixed HTML/PHP 输入上发生了 decision explosion
  • 尤其集中在 inlineHtmlStatement

方案

这一节的方案分两层。

第一层,是统一 parse 模式:

  • common/yak/antlr4util/sll_first_parse.go

这一层把:

  • SLL-first
  • fallback 到 LL
  • 错误监听
  • cache detach

这些行为收到了统一 helper 里。

第二层,是 PHP 的专门优化:

  • common/yak/php/php2ssa/html_token_source.go
  • common/yak/php/php2ssa/builder.go

这里引入了一个非常关键的能力:

  • decorateTokenSource func(antlr.TokenSource) antlr.TokenSource

它允许某个 front-end 在 lexer 和 CommonTokenStream 之间,插入一层自己的 TokenSource 装饰器。

最后真正最有效的优化,不是继续大改 grammar,而是给 PHP 加了一层 HTML token coalescing:

  • 连续 HTML token 合并成更大的块
  • PHP 起始边界和 XML 边界保留

Java 这一侧,这轮则主要是:

  • 继续 fixup 真实项目里的 AST 边界
  • 把反编译大目标 clean compile 跑通

现在的情况和重构结果

现在这条线已经形成了比较清楚的分工:

  • Java:重点在于 fixup 和真实大目标跑通
  • PHP:重点在于 front-end 性能热点真正被打下来

Java 这一侧,现在最重要的结果是:

  • 真实反编译大目标里的 AST 错误和 panic 已经清到 0
  • 前端不再是第一堵墙

PHP 这一侧,现在最重要的结果是:

  • mixed HTML 这个最难看的热点已经被明确定位并压下去

运行效率对比

这一节最漂亮的一组数据,来自 PHP。

benchmark优化前优化后提升
BenchmarkFrontendPfsenseSystemInformationFixture15.36s/op6.95s/op54.74%
分配字节15.98 GB/op7.26 GB/op54.59%
分配次数259,896,130117,823,71454.67%

本地当前主线补跑时,这个 benchmark 也还能稳定在:

  • 7.82s/op

说明这条优化已经比较稳定地落到了主线上。

Java 这一侧,重点不在某个小 benchmark,而在真实项目能不能跑通。

最终 clean compile 的结果里:

  • wall time 44:10.96
  • parse 6m14s
  • save 27m25s

它说明的核心问题是:

  • Java 前端现在已经不再是第一堵墙
  • 后面的性能重心会继续往 save / persistence 方向推进

AI Agent对于开发/实验成本的降低

之前的情况

过去做这类性能优化,最贵的成本其实往往不是“写代码”,而是:

  • 跑实验
  • 看实验结果
  • 验证思路是不是对
  • 反复跑实验
  • 换参数再跑
  • 换目标再跑
  • 换分支再跑

尤其是这种工作:

  • 有大量参数组合
  • 同一个目标要反复测
  • 还要保留日志、做对比、回头看

人工来做,成本会非常高。

问题

这轮几个关键优化,本质上都不是“改一下马上就知道对不对”的问题。

例如:

  • 常量池/SSA 搜索优化,需要反复在同一个大目标上看热点变化
  • ANTLR cache reset,需要做多组参数 sweep
  • PHP mixed HTML,需要反复跑 benchmark、pprof、对比不同方案

如果这些事情都手工做,实验成本会非常夸张。

方案

这一轮很值得单独提一句的是:

AI agent 在这里提供了大量原本很贵、很重复的实验能力。

开发者真正做的事情,是:

  • 判断方向
  • 看到实验结果
  • 决定最终方案

而 AI agent 帮忙做的事情,是:

  • 持续跑统一目标
  • 跑多组参数
  • 跑多个 worktree / 多个分支
  • 把结果整理回来

最典型的两条线就是:

  • SSA 常量池 / 搜索链实验
  • ANTLR cache reset 参数 sweep

现在的情况和重构结果

这件事带来的变化,不是“AI 替开发者写完了优化”,而是:

  • 很多原本太贵、太烦、太重复的实验,现在能持续做
  • 多 worktree、多参数、多轮复跑的成本被明显压低了

开发者可以把更多精力放在:

  • 判断根因
  • 决定取舍
  • 设计实验
  • 决策和审核最终实现

运行效率对比

这一节没有直接对应的“代码运行时间”对比表。

但它确实改变了这轮优化的产出方式:

  • 常量池优化能做多轮基线比对
  • ANTLR reset 能做整轮 sweep 和复跑
  • PHP mixed HTML 能把 benchmark、pprof、规则验证一起串起来

放在这一轮里,这件事最适合用一句话来概括:

这一轮优化让 AI 辅助真正融入了性能调优的工作流。

总结

如果把这轮 IRify 性能优化 2.0 压成一句话,那就是:

这一轮不是只做了“更快”,而是把几条最关键的路径真正做成了“更快、更稳、更可继续优化”。

这一轮最值得记住的五件事是:

1、SSA 编译和相关文档更统一了,ssa.to/docs 也跟着补齐了。

2、SSA 指令搜索开始从字符串路径走向常量池 / id 路径,真正把大项目里的重复搜索问题打下来。

3、SyntaxFlow / SFVM 的判断语句运行逻辑被重构了一轮,大量值场景下过去会拖很久的旧判断路径,已经被收住了。

4、ANTLR 这一侧,缓存清理已经不是经验值,而是做过 sweep 的“甜品点”选择;PHP mixed HTML 更是拿到了非常扎实的前后对比数据。

5、AI agent 已经开始明显降低这类开发和实验的成本。

如果说第一轮做的是“把路打通”,第二轮做的就是:

  • 把最重的热点打下来
  • 把最糟糕的长尾收掉
  • 把最关键的默认参数选稳

而接下来的第三轮、第四轮,方向其实也已经很清楚了:

  • 编译期内存继续压
  • 存储与落库继续压
  • lazy build 路径继续推进
  • 数据流分析继续细化

也就是说,这一轮不是终点,而是一个很明确的中继站。

从这里开始,IRify 已经不再只是“能跑”,而是开始真正进入“能持续变快”的阶段。


本文首发于 Yak Project 公众号,阅读原文