跳到主要内容

性能优化:IRify 内存占用优化复盘(三)

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

之前我们发布了 IRify 性能升级的第一篇技术文档,在文中详细阐述了针对 IRify 编译后端进行的一系列基础性架构优化。通过将指令间的引用从内存指针迁移为持久化 ID,并引入 Fetch 和 Save 异步 I/O 抽象,我们成功地将编译器的核心计算逻辑与缓慢的数据库持久化操作解耦,在数据库模式下获得了约20%的显著性能提升。

然而,性能优化的征程永无止境。解决一个瓶颈,往往会使下一个瓶颈凸显出来。当后端的数据库持久化不再是主要制约因素后,我们发现,编译器前端在文件处理和 AST(抽象语法树)解析阶段的固有串行性,成为了限制编译总吞吐量的“新墙”。

本文将聚焦于我们进行的第二阶段深度优化,详细介绍如何通过构建一个高效的异步处理管道(Pipe),彻底重塑了前端编译流程,并进一步完善了后端的并发数据处理模型,以应对前端带来的数据洪流,最终将 IRify 打造成真正意义上的高并发编译引擎。

**第一篇文章回顾:**YAK,公众号:Yak ProjectIRify 性能升级,突破数据库性能瓶颈

第二篇文章回顾:YAK,公众号:Yak ProjectIRify 性能升级(二):前端并发一键开挂,构建高并发编译引擎

内存优化阶段介绍

内存扫描模式

在当前版本的 IRify 和命令行工具中,我们引入了新的扫描模式:“内存模式”:

该模式执行:

“解析编译->执行规则扫描”的功能,中间指令不会进行数据库的指令储存和指令搜索,只在内存中存在。分析将会更加的快速,同时,分析得到的结果、漏洞风险信息和相关数据都会保存到数据库。

除中间代码不会保存之外,其他操作无任何变化。

这一分析方案适应频繁变动的代码或只关注分析结果的情况,不保留指令中间信息将会最大的提高运行效率。

内存优化

在解决了 I/O 和并发瓶颈,并且开始尝试内存模式扫描后,一个新的“高墙”浮现了。本文是该优化系列的第三篇,将详细复盘内存部分的优化和调整,记录我们是如何系统性地运用 Go 的性能分析工具,解决“纯内存”运行模式下的严重内存缺陷。

当前情况

但在面对中大型项目的测试时,一个幽灵浮现——程序在分析时内存占用会飙升到 3GB 以上,并且在分析结束后,这些内存像被诅咒了一样,丝毫不会被 Go 的垃圾回收器(GC)释放。

本文将完整复盘我们这场内存优化的完整过程,记录我们是如何从这个 3GB 的内存深渊出发,系统性地运用 Go 的性能分析工具 pprof,优化编译后核心 IR 最终内存占用仅 500 MB 的健壮、高效的分析引擎。

工具箱:检测-验证反馈循环

在投入战斗之前,我们首先建立了一套可靠的“侦测-验证”反馈循环机制,这是我们所有后续工作的基础。

Runtime+dump 检查

  • 测试框架:我们构建了一套测试框架,可以对不同规模的 Java 项目进行完整的 SSA IR 构建。
  • 周期性监控:在框架中,我们集成了:

增量分析定位和工作流程

  • 单点分析: g``o ``tool pprof heap_profile_xxx.pb.gz,配合 top, list, peek 等命令,分析特定时间点的内存成分。
  • 差异分析: go tool pprof --base heap_1.pb.gz heap_2.pb.gz,精确地看到两个时间点之间,哪些内存被新增或回收了。

试验:解决错误处理内存占用

我们最初的优化工作始于一个看似微小,却对内存行为模式影响深远的问题。

  • 问题: 我们的 JoinErrors 函数负责将多个错误合并成一个。之前的实现会“急切地”将所有错误信息拼接成一个巨大的字符串 msg 并存储起来。
<include('gofunc JoinErrors(errs ...error) error {
...
lenOfErrors := len(errs)
for i, err := range errs {
msg += err.Error()
...
}
...
}lang-database-sql')> as $db;<include('golang-user-input')> as $input;$db.QueryRow(* #-> as $param);$param & $inputas $mid;

发现:通过我们的 dump -> GC -> dump2 快照机制,我们发现这种模式对 GC 并不友好。即使最终的错误对象不再被引用,这个预先计算好的、巨大的 msg 字符串也常常会导致瞬时内存占用过高,并且需要更长的时间才在触发GC时被回收。

解决方案: 我们将逻辑修改为了懒加载。不再预先构建 msg 字符串,而是修改我们的自定义错误类型,让它只在 Error() 方法被实际调用时才去动态地构建最终的错误信息。这个小小的改动,极大地改善了程序的内存平滑度,降低了 GC 压力。

Antlr 幽灵引用

全局缓存的去除

在对一个中型项目进行完整分析后,pprof 报告将矛头直指 ANTLR。我们发现,在编译任务结束并手动触发 GC 后,最终残留的内存几乎全部由 antlr.* 相关的对象占据。

当时的 pprof 数据 (示意):

<include('golang-database-sql')> as $db;<include('golang-user-input')> as $input;$db.QueryRow(* #-> as $param);$param & $input as $mid;
  • 内存检查思路:flat 内存显示,不仅是 ANTLR 的缓存(ATNConfig),连 AST 节点本身(CommonToken, ParserRuleContext)都没有被回收。这明确地指向了内存泄漏。

Antlr工作机制和全剧缓存

在 Antlr 中,运行将会使用如下的 API:

以 java 为例:

func Frontend(src string) (javaparser.ICompilationUnitContext, error) {        lexer := javaparser.NewJavaLexer(antlr.NewInputStream(src))        tokenStream := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel)        parser := javaparser.NewJavaParser(tokenStream)        parser.SetErrorHandler(antlr.NewDefaultErrorStrategy())        ast := parser.CompilationUnit()        return ast, errListener.Error()}

其中,javaparser 是 antlr 配置文件生成的代码包,其中NewJavaLexerNewJavaParser 函数如下:

查找fmt.Sprintf的 use 链中是否包含危险函数db.QueryRow,对应的内置规则如下:

var javalexerLexerStaticData struct {
once sync.Once
serializedATN []int32
channelNames []string
modeNames []string
literalNames []string
symbolicNames []string
ruleNames []string
predictionContextCache *antlr.PredictionContextCache
atn *antlr.ATN
decisionToDFA []*antlr.DFA
}
// NewJavaLexer produces a new lexer instance for the optional input antlr.CharStream.
funcNewJavaLexer(input antlr.CharStream) *JavaLexer {
JavaLexerInit()
l := new(JavaLexer)
l.BaseLexer = antlr.NewBaseLexer(input)
staticData := &javalexerLexerStaticData
l.Interpreter = antlr.NewLexerATNSimulator(l, staticData.atn, staticData.decisionToDFA, staticData.predictionContextCache)
l.channelNames = staticData.channelNames
l.modeNames = staticData.modeNames
l.RuleNames = staticData.ruleNames
l.LiteralNames = staticData.literalNames
l.SymbolicNames = staticData.symbolicNames
l.GrammarFileName = "JavaLexer.g4"
// TODO: l.EOF = antlr.TokenEOF
return l
}
var javaparserParserStaticData struct {
once sync.Once
serializedATN []int32
literalNames []string
symbolicNames []string
ruleNames []string
predictionContextCache *antlr.PredictionContextCache
atn *antlr.ATN
decisionToDFA []*antlr.DFA
}
// NewJavaParser produces a new parser instance for the optional input antlr.TokenStream.
funcNewJavaParser(input antlr.TokenStream) *JavaParser {
JavaParserInit()
this := new(JavaParser)
this.BaseParser = antlr.NewBaseParser(input)
staticData := &javaparserParserStaticData
this.Interpreter = antlr.NewParserATNSimulator(this, staticData.atn, staticData.decisionToDFA, staticData.predictionContextCache)
this.RuleNames = staticData.ruleNames
this.LiteralNames = staticData.literalNames
this.SymbolicNames = staticData.symbolicNames
this.GrammarFileName = "java-escape"
return this
}

通过代码可以看出:

Antlr 默认的储存模式将会使用全局变量 javalexerLexerStaticDatajavaparserParserStaticData 储存数据,其中 predictionContextCache, atn decisionToDFA 将会保存这些数据。

  • 隔离实验:证明清理逻辑本身无罪

解决全局缓存问题

  • 思路: 我们意识到,全局缓存既会导致项目间的状态污染,也难以管理生命周期。而为每个文件创建新缓存又会损失性能。因此,我们设计了**“项目级缓存”**的架构。

  • 操作流程: 我们创建了一个“编译上下文”对象,它持有一套专属于一次编译任务的 ANTLR 缓存。这个缓存在项目内部复用,在项目结束后可以被完整销毁。
  • 结果: 实施后,大部分 AST 内存都可以被回收了。但我们通过 pprof 的差异分析发现,仍然有少量细节性的 AST 节点顽固地留在内存中。战斗还未结束。

LazyBuilder 闭包

问题

  • 思路: 为什么还有少量节点无法回收?我们开始审查所有可能持有 AST 节点的长生命周期对象。很快,我们锁定了嫌疑人:一个用于延迟构建的 LazyBuilder
  • 内存泄漏逻辑: 我们发现,在第一遍 AST 遍历时,为了实现延迟加载,我们创建了闭包,这些闭包捕获了 AST 节点,并被添加到了一个长生命周期的 LazyBuilder 实例中。这就形成了一条致命的引用链:SSABuilder -> LazyBuilder -> 闭包 -> AST节点

解决:记录更新销毁,分析路径执行

  • 思路: 为何问题依旧?我们退回到最原始的调试方法:日志追踪。

  • 操作流程: 我们为 LazyBuilder 的创建 (New) 和销毁 (Build) 方法添加了唯一的 ID 和日志。我们构造了一个全局的 map 来登记所有被创建的 LazyBuilder 实例,并在 Build 方法被调用时,从 map 中移除它。
  • 惊人的发现: 在程序结束时,我们打印出 map 中剩余的内容,发现大量的 LazyBuilder 实例根本没有被调用 Build() 方法! 问题的根源,是一个隐藏在复杂逻辑分支中的缺陷,导致我们的清理函数没有为所有对象都被调用。
  • 最终修复: 我们调整了主流程,确保所有被创建的 LazyBuilder 实例都被登记,并在编译任务的最后,有一个统一的、保证会被执行的清理循环,遍历并调用所有登记实例的 Build() 方法。
  • 结果: 成功! 在修复了这个逻辑问题后,所有 AST 和 ANTLR 相关的临时内存在清理后,都被干净利落地回收了。

SSA IR 整理

在解决了所有内存泄漏后,我们得到了一个健康的、最终内存占用约 1.2GB 的程序。此时,pprof 的角色从“查漏”,变成了“减负”。

懒加载

  • 思路:我们发现,即使没有泄漏,IR 本身也很大。通过 pprof 和手动计算,我们发现 anValue 等核心结构体因为包含了多个空的 slice 头和 map 指针,体积依然偏大。
  • 操作流程: 我们将这些字段(例如 userList, member map等)的创建时机,从“对象创建时”修改为“字段首次被访问时”的懒加载模式。这显著降低了每个 SSA 对象的基础大小。

rune 问题解决

  • **思路:**pprof top 显示,memedit.SafeString 为了处理多语言编码,在创建时就“急切地”将源码文本从 byte 转换为了 rune。对于以 ASCII 为主的源码,这会造成 4 倍的内存膨胀。
  • pprof 数据:69.10MB 5.65% github.com/yaklang/yaklang/common/utils/memedit.(*SafeString).ensureRunes
  • 操作流程: 我们再次应用懒加载机制。SafeString 在创建时只持有原始的 byte。只有在第一次需要 rune 形式时,才执行转换,并将结果缓存起来。

总结

这次内存优化,最终将一个 3GB+ 的内存怪兽,变成了一个最终 IR 仅占用 500MB 的高效引擎:

1、系统性地使用工具:pprof 的 top, list, peek 和差异分析,结合日志与 runtime.GC

2、生命周期是第一公民:内存问题的根源,往往不是算法,而是对象的生命周期管理。确保每一个需要清理的对象,都有一个明确的、保证会被执行的清理路径。

3、警惕闭包:闭包是 Go 的强大工具,但也是隐藏引用、造成内存泄漏的头号嫌疑人。传递“纯数据上下文”远比直接捕获更安全。

4、懒加载机制:对于非必需或高开销的数据,不要在构造时就支付成本,而是在第一次使用时再加载。

最终,我们意识到,最高级的优化,往往回归到最朴素的工程原则:严谨的逻辑、清晰的生命周期,以及对每一个对象从生到死的完整追踪。


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