Hotdry.
compiler-design

信号式与查询式编译器架构:高性能增量编译的内存管理策略

深入分析信号式与查询式编译器架构的核心差异,探讨在大型项目中实现高性能增量编译的内存管理策略与工程权衡。

随着语言服务器协议(LSP)的普及和现代 IDE 对实时反馈的需求,编译器架构正在经历一场静默的革命。传统的流水线式编译器架构在面对增量编译和交互式开发场景时显得力不从心,而两种新兴架构范式 —— 信号式(Signals)与查询式(Query-Based)—— 正在重新定义编译器的设计哲学。本文将从工程实践角度,深入探讨这两种架构的核心差异,并重点分析在大型项目中实现高性能增量编译的内存管理策略。

传统流水线架构的局限性

经典的编译器教学通常将编译器描述为一个线性流水线:源代码 → 抽象语法树(AST) → 中间表示(IR) → 汇编代码 → 链接器 → 最终二进制文件。这种架构简单直观,对于简单的编程语言来说实现起来相对直接。

然而,当开发者在一个文件中输入单个字符时,背后需要进行大量工作。理想情况下,我们希望尽可能少地执行工作。虽然可以在每个阶段添加缓存并尝试制定良好的失效启发式方法,但这很快就会变得难以维护。正如 Marvin Hagemeister 在其文章中指出:"当开发者通过输入单个字母更改单个文件时,背后有很多工作在进行。理想情况下,您希望尽可能少地执行工作。"

查询式架构:按需计算的哲学

查询式编译器的核心思想是将编译器视为可以运行查询的对象,而不是一系列转换的流水线。当用户在编辑器中输入时,LSP 会询问编辑器:"在这个文件的这个特定光标位置有什么建议?" 当您点击标识符上的 "转到定义" 时,您正在要求编译器返回跳转目标(如果有的话)。

三大核心组件

查询式编译器有三个关键构建块:查询(Queries)、输入(Inputs)和 "数据库"(Database)。核心思想是一切都由查询和输入组成。除非执行查询,否则不会运行任何内容。

查询是纯函数,通常定义为具有两个参数的函数:数据库和参数。在 TypeScript 中,它看起来像这样:

type Query<T, R> = (db: Database, arg: T) => R;

输入是状态对象,可以写入。当磁盘上的文件更改时,您需要通过输入告诉编译器应该使其失效,以便清除缓存条目,下次查询请求时重新处理该文件。

数据库是所有查询所在的位置,也是编译器内部状态的存储容器。

缓存策略与修订号系统

为了进一步加速,查询可以轻松缓存,因为它们应该是纯的。它们不应该有任何副作用。这意味着您始终可以重新执行查询并获得完全相同的结果。这个属性使其非常适合缓存。

但要使缓存正确,有一个关键细节:它还需要在哈希缓存键中包含传递的参数。这反过来意味着当Query A在许多地方被调用并传递多个不同的参数时,每个参数将创建该查询的新实例,并具有自己的缓存返回值。

系统内部有一个全局修订号计数器,每次更改输入时都会递增。每个节点都有一个changed_atverified_at字段,可用于检查缓存值的状态。

interface Node<T> {
    changed_at: Revision;
    verified_at: Revision;
    value: T;
    dependencies: Node<any>[];
}

这告诉我们是否可以重用节点的缓存结果。也就是说,由于只在一个方向上跟踪依赖关系,我们需要始终对查询的所有依赖关系进行脏检查,直到叶节点,除非verified_at等于当前修订号,这允许我们提前退出。

信号式架构:UI 渲染的优化选择

信号式架构采用 push-pull 系统,非常适合 UI 渲染。更改通常需要立即显示,您需要确保整个屏幕同步。所有渲染的信号必须始终显示它们所属的同一修订号的值。屏幕上半部分显示新值而下半部分仍呈现陈旧值的情况永远不应发生。这通常被称为 "glitch"。

在信号系统中,对源信号的更改会将其标记为脏,然后它遵循所有活动订阅并标记每个派生 / 计算信号为脏,直到到达触发订阅的位置。触发部分通常称为Effect。更改通过系统推送,当到达效果时,它会重新运行并 "拉取" 新值。

关键架构差异对比

执行模型:Push-Pull vs Pull-Only

信号式架构是 push-pull 系统,而查询式架构是 pull-only 系统。这是两者最根本的区别。信号系统主动推送更改,确保 UI 的一致性;查询系统被动响应请求,按需计算。

正如 Hagemeister 所解释:"在信号系统中,对源信号的更改会将其标记为脏,然后它遵循所有活动订阅并标记每个派生 / 计算信号为脏... 查询式编译器的工作方式不同,它们是需求驱动的。您必须_要求_它们重新执行。"

内存管理策略

查询式架构的内存优化

  1. 单向依赖跟踪:与信号跟踪双向依赖相比,查询系统只在一个方向上跟踪依赖关系,显著减少内存开销
  2. 惰性缓存失效:仅在查询执行时检查依赖关系,而不是在输入更改时立即传播失效
  3. 分层缓存策略:根据查询频率和计算成本实施多级缓存

信号式架构的内存特点

  1. 双向依赖图:维护完整的依赖关系图,确保一致的 UI 状态
  2. 即时失效传播:输入更改时立即标记所有相关信号为脏
  3. 订阅管理开销:需要管理活动订阅列表

并发与并行化

查询式系统在并行化方面具有天然优势。由于查询应该是纯函数且无副作用,只要保证每个查询一次只能由一个线程执行,就可以并行化许多任务。由于查询往往相当细粒度,您通常可以终止线程并使用最新修订号重新生成它。

相比之下,信号系统由于需要维护一致的 UI 状态,并行化更加复杂。更改必须按顺序处理,以确保不会出现竞态条件或状态不一致。

内存管理工程实践

1. 缓存大小与淘汰策略

在大型项目中,查询式编译器可能轻松拥有超过 10 万个节点。在这种规模下,内存成为真正的性能问题。有效的缓存管理策略包括:

  • LRU(最近最少使用)淘汰:对于不常访问的查询结果
  • 基于大小的限制:设置最大缓存大小,超过时清除最旧或最大的条目
  • 分层存储:将频繁访问的结果保留在内存中,将不频繁访问的结果移至磁盘

2. 依赖关系跟踪优化

单向依赖跟踪虽然减少了内存开销,但可能导致某些场景下需要全量脏检查。优化策略包括:

  • 增量验证:仅检查自上次验证以来可能已更改的依赖关系
  • 依赖关系分组:将相关查询分组,共享依赖关系跟踪
  • 智能提前退出:当verified_at等于当前修订号时立即停止检查

3. 修订号系统的实现细节

修订号系统是查询式架构正确性的核心。实现时需要考虑:

struct RevisionManager {
    current_revision: AtomicU64,
    // 每个节点的修订号信息
    node_revisions: ConcurrentHashMap<NodeId, NodeRevision>,
}

struct NodeRevision {
    changed_at: u64,    // 最后更改的修订号
    verified_at: u64,   // 最后验证的修订号
    dependencies: Vec<NodeId>,
}

4. 内存池与对象重用

对于频繁创建和销毁的中间结果,使用内存池可以显著减少分配开销:

  • AST 节点池:重用解析过程中创建的 AST 节点
  • 类型对象池:缓存类型检查过程中创建的类型对象
  • 查询结果池:重用相同查询的返回结果

性能监控与调优参数

关键性能指标

  1. 缓存命中率:目标 > 85%
  2. 平均查询延迟:目标 < 10ms(对于交互式查询)
  3. 内存使用峰值:根据项目规模动态调整
  4. 并行化效率:CPU 利用率与查询吞吐量

可调参数建议

# 查询式编译器配置示例
compiler:
  caching:
    max_memory_mb: 1024
    lru_size: 10000
    ttl_seconds: 3600
    
  dependency_tracking:
    enable_incremental: true
    batch_size: 100
    early_exit_threshold: 0.8
    
  parallelism:
    max_threads: 8
    query_timeout_ms: 5000
    retry_on_deadlock: true

工程权衡与选择指南

何时选择查询式架构

  1. 编译器 / LSP 开发:需要按需计算和增量编译
  2. 大型代码库:项目规模超过 10 万行代码
  3. 交互式工具:需要低延迟响应的开发工具
  4. 资源受限环境:需要精细控制内存使用

何时选择信号式架构

  1. UI 框架:需要确保视觉一致性的前端框架
  2. 实时数据流:数据频繁更新且需要即时反映
  3. 状态管理库:复杂的应用状态管理
  4. 游戏引擎:需要帧同步的实时系统

未来展望:混合架构的可能性

有趣的是,不同的系统在幕后都采用了相似的构建块和概念来实现增量系统。我不禁思考,如果我们的 JavaScript 工具从一开始就设计为增量式的,它们会是什么样子。像 vite 这样的工具如果构建为查询式系统会是什么样子?在精神上,开发服务器是相似的,因为它是一个我们不断查询数据的东西,除了从服务器推送的 HMR 更新。也许信号和查询架构的混合才是黄金门票?

结论

信号式与查询式编译器架构代表了两种不同的设计哲学,各自在特定场景下表现出色。查询式架构通过按需计算、单向依赖跟踪和智能缓存策略,为大型项目的增量编译提供了可扩展的解决方案。而信号式架构则通过 push-pull 模型和双向依赖跟踪,确保了 UI 渲染的一致性和实时性。

在实际工程实践中,选择哪种架构取决于具体的使用场景、性能要求和资源约束。对于编译器开发者而言,理解这两种架构的核心差异和内存管理策略,将有助于设计出更高效、更可扩展的编译工具。

随着编程语言和开发工具的不断发展,我们可能会看到更多混合架构的出现,结合两种范式的优点,为开发者提供更好的开发体验和更高效的编译性能。


资料来源

  1. Marvin Hagemeister, "Signals vs Query-Based Compilers" (2026)
  2. Rust Compiler Development Guide, "Salsa - Incremental Recomputation"
  3. Olle Fredriksson, "Query-based compiler architectures" (2020)
查看归档