性能优化:IRify 增量编译技术详解(五)
我们正在解决 IRify 处理大项目时的性能问题,当前 IRify 在处理大型项目时面临的主要性能瓶颈:
为了解决这些问题,我们引入了智能的依赖追踪 + 变更检测机制,实现「有变编译,无变复用」的高效构建模式。
什么是增量编译?
增量编译,这是一种基于已存在项目进行二次编译的一种技术。其核心思想是基于项目历史构建状态,仅对发生变更的源文件及其依赖项进行重新编译。通过对比文件修改时间或内容哈希,系统自动跳过未变化的编译单元,从而在保证输出结果一致性的前提下,大幅减少大型项目的构建时间。
与全量编译相比,增量编译通过智能识别变更并复用历史编译结果,在以下方面展现出显著优势:
- 增量编译构建时间仅与变更范围成正比。它只重新编译新增或修改的文件及其直接依赖,对未变动的代码直接复用之前的编译结果。这使得日常开发中的小改动构建时间缩短60% 到 90% 以上,实现“秒级”甚至“亚秒级”的快速反馈。
- 全量编译漫长的构建时间使得“编写代码 -> 编译 -> 运行测试 -> 发现问题 -> 修复”的迭代周期变得很长,阻碍了快速实验和探索。将迭代周期缩短到开发者可以接受的心理阈值内,鼓励更频繁的代码验证和重构,有助于提高代码质量和加快功能交付速度。特性维度增量编译模式传统全量模式编译范围仅编译变更文件及直接依赖编译全部文件构建时间显著缩短(通常减少60-90%)完整构建时间内存占用大幅降低高峰期占用高适用场景日常开发、频繁迭代首次构建、清理构建
多层 Layer 架构
- 统一的多层模型:Layer1(最底层)→ Layer2 → Layer3 → ... → LayerN(最上层)
- 无 Base/Diff 区分:所有层都是平等的 Layer,简化概念
- 上层覆盖下层:查找时从最上层开始,自动处理覆盖逻辑
虚拟视图(ProgramOverLay):
type ProgramOverLay struct {
Layers []*ProgramLayer // 按顺序存储所有层
FileToLayerMap *SafeMap[int] // 文件路径 -> Layer索引(快速查找)
AggregatedFS FileSystem // 聚合后的文件系统
signatureCache // Value签名缓存,用于重定位
}
文件差异计算
状态编码:
- -1:删除(在前一层存在,本层不存在)
- 0:修改(前后层都存在但内容不同)
- 1:新增(只在本层存在)
计算方式:通过 calculateFileSystemDiff 比较两个文件系统,生成差量映射
聚合文件系统
聚合策略:
- 处理删除:如果文件在底层存在但在上层被标记为删除(hash=-1),则从聚合结果中移除
- 文件集合计算:使用 getAggregatedFilesSet,hash 值求和为 1 的文件才会被包含
实现细节:
// 从最上层到最下层查找文件
for i := len(p.Layers) - 1; i >= 0; i-- {
if foundInLayer {
aggregated.AddFile(filePath, content)
break // 找到即停止,实现上层覆盖
}
}
数据库存储策略
元数据存储:
- IsOverlay = true:标记为增量编译
- OverlayLayers = [layer1, layer2, ...]:存储所有层的 program 名称,每个 layer 的 program 独立存储,保持独立性
文件存储:每个 layer 的文件存储在 IrSource 表中,通过 ProgramName 关联
增量变量流程
增量编译分为3种情况:
- 第一次编译(Base Program):全量编译,标记为增量编译流程的起点
- 增量编译(Diff Program):基于已有程序编译差量,创建或扩展 Overlay 详细流程
- 再次增量编译(Diff Program):先对上一次增量编译的结果进行聚合,然后使用聚合的文件系统进行增量编译
输入:文件系统 + 配置
↓
全量编译所有文件
↓
保存 Program 到数据库
↓
设置 IsOverlay = true
设置 OverlayLayers = [programName]
↓
保存配置
↓
返回 Base Program
增量编译流程(Diff Program)
输入:新文件系统 + Base Program Name
↓
Step 1: 从数据库加载 Base Program
↓
Step 2: 判断 Base Program 类型
├─ 是 Overlay → 使用 AggregatedFS
├─ 是差量 Program → 创建临时 Overlay (Layer1 + Layer2)
└─ 是全量 Program → 重建文件系统
↓
Step 3: 计算文件差异 (FileHashMap)
├─ 比较 BaseFS 和 NewFS
├─ 生成 FileHashMap (-1:删除, 0:修改, 1:新增)
└─ 只编译变更文件
↓
Step 4: 编译差量 Program
├─ 编译变更文件
├─ 设置 BaseProgramName
├─ 设置 FileHashMap
└─ 保存到数据库
↓
Step 5: 创建/扩展 ProgramOverLay
├─ Base 是 Overlay → extendOverlayWithNewLayer()
└─ Base 不是 Overlay → NewProgramOverLay()
↓
Step 6: 聚合文件系统
├─ 从最上层到最下层查找文件
├─ 上层覆盖下层
├─ 处理删除文件 (hash=-1)
└─ 生成 AggregatedFS
↓
Step 7: 保存 Overlay 元数据
├─ 收集所有 Layer 名称
├─ 设置 IsOverlay = true
├─ 设置 OverlayLayers = [layer1, layer2, ...]
└─ 只更新当前 Program(不更新 Layer)
↓
Step 8: 更新缓存和配置
↓
返回 Diff Program (包含 Overlay)
流程图(Mermaid):
如何在IRify中开启增量编译?
IRify 提供了两种对增量编译的使用方式。
在重新编译中自动增量编译
目前增量编译作为SSA 项目探测.yak脚本开放的编译选项存在:
当启动增量编译选项勾选后,那么这个编译出来的项目就会被认为是增量项目,在数据库中会标注 is_overlay 为 true。
对一个增量项目进行重编译会自动进行增量编译的逻辑,程序会自动计算 diff 并生成 diff 文件系统。
以下面代码为例进行测试:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/unsafe", unsafeHandler)
http.HandleFunc("/safe", safeHandler)
fmt.Println("Server starting on :8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Printf("Server error: %v\n", err)
}
}
package main
import (
"fmt"
"net/http"
"os/exec"
)
funcsafeHandler(w http.ResponseWriter, r *http.Request) {
output, err := executeCommandSafe("hello")
if err != nil {
http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, output)
}
funcexecuteCommandSafe(userInput string) (string, error) {
cmd := exec.Command("echo", userInput)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("command execution failed: %v", err)
}
return string(output), nil
}
完成编译以后,可以在项目历史的编译历史中查看文件树:
接着在项目管理页面的操作框中的编译选项中勾选重新编译:
然后,修改的测试代码如下:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/unsafe", unsafeHandler)
http.HandleFunc("/safe", safeHandler)
fmt.Println("Server starting on :8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Printf("Server error: %v\n", err)
}
}
package main
import (
"fmt"
"net/http"
"os/exec"
)
funcunsafeHandler(w http.ResponseWriter, r *http.Request) {
cmdParam := r.URL.Query().Get("cmd")
if cmdParam == "" {
http.Error(w, "Missing 'cmd' parameter", http.StatusBadRequest)
return
}
output, err := executeCommandUnsafe(cmdParam)
if err != nil {
http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, output)
}
funcexecuteCommandUnsafe(userInput string) (string, error) {
cmd := exec.Command("sh", "-c", "echo "+userInput)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("command execution failed: %v", err)
}
return string(output), nil
}
在项目历史的变量历史中可以发现两个项目,其中旧项目为基项目而新项目为增量项目(PS:后续版本中会考虑优化前端显示,目前版本无法区分普通项目和增量项目)。
上述测试中进行了两次扫描,第一次发生在基项目中,第二次发生在增量项目中,差异文件只有unsafe.go。
测试验证了增量扫描的跨项目聚合能力。在增量项目中,虽然只有 unsafe.go文件发生变更并触发重新编译,但系统通过项目聚合机制,在最终结果中完整呈现了包含基项目 main.go文件在内的文件树。这确保了增量分析的高效性与结果完整性的统一。
这里对新增漏洞显示进行了优化,解决了新增漏洞的显示范围受限问题。现在,当对增量项目进行扫描时,系统能够跨项目边界显示新增漏洞,确保新增漏洞的上下文信息完整呈现。
在项目配置中手动增量编译
除了自动增量编译以外,还可以手动指定某个项目并进行增量编译,在 SSA 项目探测.yak脚本开放的编译选项存在基础程序名称的输入框,在这里输入某个项目的全名就会设置本次编译为基于该项目的增量编译。
注意:这里需要输入项目在数据库中的全名以精确定位项目,目前前端没有展示项目的全名,在后续版本中会进行优化。
后续版本的优化
目前增量编译还处于测试阶段,我们正在计划监控一些开源项目并定期进行增量编译。在我们的 CI 中已经有差异文件系统的部分监控,可以在每个 PR 合并之前自动运行 golang syntaxflow rule的规则扫描(详情可以查看 diff-code-check.yml)。
本文首发于 Yak Project 公众号,阅读原文。
