跳到主要内容
内存不是省出来的,是砍出来的

内存不是省出来的,是砍出来的

原石
原石

· 阅读约 5 分钟

上个月,一个叫 Rust Glancer 的项目发了介绍文章。Rust 的 LSP,目标是替掉 rust-analyzer,卖点就一个:内存。附的视频里,索引一个正常规模的项目,RAM 全程压在 100 MB 以下。这东西四个月前只想做个 Rust 版的智能 ctags,一路长成了 LSP。功能在长,内存预算没长。

我第一反应是,又来一个拿数据结构卷内存的。读完设计那节,发现猜反了——它没有任何聪明结构。它赢,是因为砍掉了一个承诺。

先把它的做法压成伪代码。不是源码,是形状:

on_save():  freeze(analysis);  spill_to_disk(index);
on_edit():  shallow(current_function);   /* 只浅析当前函数体 */
on_query(): load_shard(index);           /* 按需从盘上装回来 */

三行。核心就一个决定:不做逐键增量。全量分析在保存那一刻冻结,卸到文件系统;打字的时候,只对当前函数体做浅分析,其余全用上次冻结的结果;查询来了,按需把分片装回来。

那 rust-analyzer 的内存花哪了。它答应了一件事:任何一次按键之后,任何查询立即成立。悬停、跳转、补全,永远反映你刚敲下去的那个字符。兑现这个承诺靠增量查询数据库——salsa 那套。而增量意味着中间结果一个都不能扔:你不知道下一次按键会让哪个旧结果复活,所以全得留在内存里候着。不是它没做缓存,是承诺逼着缓存只进不出。

/* 增量查询库被逼成的形状,大意: */
fn get(&mut self, q: Query) -> &Value {
    if let Some(v) = self.cache.get(&q) { return v; }
    let v = self.compute(&q);
    self.cache.insert(q, v);   // 只进,不出
    &self.cache[&q]
}

再叠上 rowan 语法树的碎片化,内存就是这么没的——这两条账是作者文章里自己算的。注意,这不是 rust-analyzer 写得烂。写 Glancer 这位,rustc、clippy、rust-analyzer 都交过贡献。他不是不懂 salsa 才绕开它,是懂了才砍的。

多数人省内存的思路是:承诺不动,优化实现。给缓存加 LRU,给语法树加序列化层,冷热分离,分片换页。每一条都是复杂度上叠复杂度:原来的增量机器一行没少,又多出一层“什么时候把东西挪出去”的状态机。抽象税。

# 保住承诺的省法:
+   缓存加 LRU 淘汰
+   语法树加磁盘序列化层
+   再来一层冷热分离管理器
# Glancer 的省法:
-   不再承诺逐键之后一切成立
+   只承诺保存之后成立

下面那两行短,但它是整篇文章。内存不是被优化下来的,是承诺缩水之后自己掉下来的。

代价明码标价。新加的 import、struct、trait,要等文档保存才进索引。let x: Foo 写到一半,而 Foo 是你三秒前在隔壁文件定义的——查不到,等 Ctrl+S。

我认为这个价划算到近乎作弊。悬停、跳转、补全,全发生在你停下来的时候,不是打字的正中间。逐键全量是个长尾承诺,rust-analyzer 用整套 salsa 为这个长尾付全价。真要逐键精度的场景,全价值。多数人多数时间,不值。

数字顺带看一眼。M1 8GB 那台,全量索引 9 秒,对 rust-analyzer 的 14 秒。快五秒不是重点——重点是那台 8 GB 的老机器开着它,还剩得下内存干别的。

同一个决定,红利发了两笔。第一笔,重启即恢复:索引本来就在文件系统上,重开编辑器就是打开文件。

restart: mmap("shard_*.idx");  /* 恢复 = 打开文件 */

第二笔,agent。agent 改代码不按键,它在编辑器外整文件整文件地写。传统文件监视器被这种写法打爆,动不动触发重索引。Glancer 干脆自己写了个监视器,把外部变更事件降级、攒批。

watcher_prio[external_edit] = LOW;  /* 攒一批再失效 */

逐键精度对 agent 本来就没有意义——agent 没有键。用法变了,承诺不跟着变,还在旧承诺上加缓存,那才是过度设计。

还有个砍法更狠:砍自己的轮子。作者最早给 std trait 搭了一套特判,Iterator、Fn 这些走 hack。后来删了,集成 Chalk——rust-analyzer 用的同一个 trait solver。他的说法大意是,换完整体反而比自研那套更简单。

- match trait_name {
-     "Iterator" | "DoubleEndedIterator" => hack_iter(..),
-     "Fn" | "FnMut" | "FnOnce"          => hack_fn(..),
-     _ => bail(),
- }
+ chalk_solve(goal)

砍功能是减法;砍自己造的轮子,有时是更干净的减法。我手头那个玩具键值存储,过期策略自己撸过一版小根堆,后来换成最笨的定时全扫——量级根本不到,删了两百行。心里疼了两天 😅,之后每次读到那段都舒坦。

说句题外话。这项目大量用 LLM 写,作者特意声明不是 vibe coding:每个 PR 他自己审,代码的错算他头上,别统一打成“AI 生成的垃圾”。这是对的边界——模型写函数、写结构体操作,判断人下。而且我几乎可以肯定,“砍掉逐键分析”这个判断不是模型给的。你跟模型说内存太高,它给你的永远是加一层:磁盘缓存、分页、LRU、两级索引。它不会说“把这个需求砍了吧”。保留所有可能性是它的出厂设置;砍可能性,才是人的活。

拉回来。它当然不完整:缺功能,有已知 bug。proc macro 只打算做不实际执行代码的那种支持,build script 一律不跑——又是砍承诺,这次砍的是“帮你执行任意代码”。作者自己说,要功能全、逐键精度的项目,默认选择仍是 rust-analyzer。我不觉得这是谦虚,是账算得清。缺的功能会补;方向错了,功能补得越多,沉得越快。

有一处我没完全想明白:浅层分析和冻结索引的边界。连续跳两个文件、第二个文件里用着第一个文件刚改的类型,会怎样,文章没细说。好在 VS Code 插件就能装,仓库也能自己 build,我准备装一个试试。

所以下次内存爆了,先别开 profiler 找哪个结构能压。先问一句:这玩意儿答应了谁、答应了什么。数据结构决定内存的下限,承诺决定上限——承诺,才是最贵的那种数据结构。

原石
原石

把代码当文章写的系统工程师,以源码立论、单线程式拒绝复杂度。

查看主页 →