Neovim 使用 ELP 内存占用过高的一次排查记录

最近在 Neovim 中使用 ELP(Erlang Language Platform) 作为 Erlang 的 LSP 服务器时,遇到了一次比较典型的内存占用过高问题。

ELP 本身是 Rust 实现,语义能力比较强,理论上性能也不错。但在实际项目中,ELP 进程的常驻内存一度达到 1.3GB~1.4GB,明显偏高。

本文记录这次排查过程,以及最终定位到的根因。


一、问题背景

项目是一个中等规模的 Erlang 服务端工程:

  • Erlang 源码与头文件数量:约 1000+ 个 .erl / .hrl
  • 项目磁盘占用:数百 MB
  • 编辑器:Neovim
  • LSP:ELP
  • 初始现象:ELP 进程 RSS 常驻约 1.3GB~1.4GB

一开始的直觉是:这个内存占用有些不正常。

虽然 ELP 是偏重型的语义分析型 LSP,不是简单的补全工具,但对这个规模的项目来说,1GB+ 的常驻内存仍然偏高。


二、初步优化尝试

最开始先做了几个常规方向的优化。

1. 使用 .elpignore 排除无关目录

首先创建 .elpignore,排除构建产物、依赖目录、日志目录、测试目录、运行数据目录等,只保留必要的 include 目录。

处理后,ELP 索引的源码数量明显下降,但内存只从约 1.3GB+ 降到约 1.0GB+,收益有限。

这说明确实有一部分无关文件被 ELP 处理了,但这不是主要矛盾。


2. 关闭部分重量级 diagnostics

随后尝试关闭一些重量级 diagnostics,例如 cross reference、unused variable 等,只保留比较轻量的检查。

内存进一步下降了一些,但仍然在 800MB~1GB+ 区间。

这说明 diagnostics 有成本,但仍然不是根因。


3. 切换 Erlang / OTP 版本

早期环境中,ELP 启动的 Erlang sidecar 进程存在 OTP 版本兼容问题。切换到符合 ELP 要求的 OTP 版本后,子进程崩溃问题消失。

但 ELP 主进程的内存占用仍然偏高。

这说明 OTP 兼容问题只是一个干扰项,不是这次内存异常的根因。


三、确认不是 RSS 虚高

为了判断是否只是 top / htop 看到的 RSS 虚高,直接查看进程的 smaps_rollup

PID=<elp_pid>
cat /proc/$PID/smaps_rollup | egrep 'Rss|Pss|Private|Shared|Swap'

典型结果类似:

Rss:             1300000+ kB
Pss:             1300000+ kB
Private_Dirty:  1200000+ kB

重点看三个指标:

Rss
Pss
Private_Dirty

如果只是 RSS 虚高,通常 PSS 不会这么高。但这里的结果是:

PSS ≈ RSS
Private_Dirty 很高

这说明不是共享库、mmap、文件缓存导致的 RSS 虚高,而是 ELP 主进程确实持有了大量私有堆内存。

换句话说,这次不是观察工具误判,而是 ELP 真的吃了这么多内存。


四、使用 .elp.toml 收敛项目模型

为了避免 ELP 自动 discovery 过宽,开始使用 .elp.toml 明确声明项目结构。

示例配置:

[build_info]
apps = [
  { name = "app", dir = ".", src_dirs = ["src"], include_dirs = ["include"], deps = [], build_deps = [] }
]
deps = []
build_deps = []

[eqwalizer]
enable_all = false

这里有几个注意点:

  1. src_dirs 只应该放真正需要 LSP 索引的源码目录。
  2. include_dirs 只应该放头文件目录。
  3. 不要把生成代码目录、运行数据目录、构建产物目录误放进 src_dirs
  4. 某些 ELP 版本要求 deps / build_deps 在 app 对象中也存在,否则会报 schema 解析错误。

如果遇到类似:

missing field `build_deps`

可以把 app 配置写成:

[build_info]
apps = [
  { name = "app", dir = ".", src_dirs = ["src"], include_dirs = ["include"], deps = [], build_deps = [] }
]
deps = []
build_deps = []

这样可以避免 TOML 表归属带来的歧义。


五、关闭 EqWAlizer 后的效果

通过以下配置关闭默认全量 EqWAlizer 分析:

[eqwalizer]
enable_all = false

关闭后,内存确实下降了一部分,但没有根本解决问题。

排查中的现象大致是:

关闭 EqWAlizer 后,ELP 内存下降了一些;
但仍然维持在 1GB 左右。

因此可以判断:

EqWAlizer 有内存成本;
但不是这次 1GB+ 常驻内存的主因。

六、按目录拆分测试

接下来开始分区测试:只让 ELP 索引某一个源码子目录,观察内存变化。

测试配置模板如下:

[build_info]
apps = [
  { name = "app", dir = ".", src_dirs = ["某个子目录"], include_dirs = [], deps = [], build_deps = [] }
]
deps = []
build_deps = []

[eqwalizer]
enable_all = false

每次修改 .elp.toml 后,重启 ELP:

pkill -9 -f 'elp server'

然后重新打开 Neovim,再观察 ELP 进程内存:

ps aux | grep elp

或者:

PID=<elp_pid>
cat /proc/$PID/smaps_rollup | egrep 'Rss|Pss|Private|Shared|Swap'

七、分区测试结果

测试结果大致如下:

测试范围文件数量RSS 估算
普通单文件最小测试1约 450 MB
主要业务源码目录数百个文件约 600 MB+
协议 / 辅助源码目录数十个文件约 500 MB
生成数据源码目录数十个文件约 1.0 GB+
完整源码目录数百个文件约 1.0 GB+

这个结果非常关键。

原本以为内存高是因为业务源码数量多,但测试结果表明:

真正异常的不是主要业务源码目录;
而是生成数据源码目录。

虽然生成数据源码目录的文件数量并不多,但只索引它时,ELP 的内存几乎等同于索引完整项目。

这说明问题不在“文件数量”,而在“某些文件的结构和规模”。


八、定位巨型生成 Erlang 模块

继续统计生成数据目录下的 .erl 文件大小:

find <generated_data_dir> -type f -name '*.erl' -printf '%s %p\n' | sort -nr | head -50

发现其中存在多个 MB 级别的自动生成 .erl 文件。

再统计行数:

wc -l <generated_data_dir>/*.erl

部分文件达到:

数十万行
二十多万行
十多万行

这些文件通常不是人类手写代码,而是由配置表、Excel、CSV 或其他数据源生成的 Erlang 数据模块。

常见结构类似:

get(1) -> {...};
get(2) -> {...};
get(3) -> {...};
...

或者包含大量 tuple、list、map literal。

这种文件对运行时可能有一定优势,但对 LSP、编译器前端、xref、dialyzer 等工具都非常不友好。


九、单文件验证

为了确认问题不是整个目录,而是巨型生成文件本身,单独复制一个巨大生成模块到临时目录,只让 ELP 索引它。

测试结果类似:

测试范围RSS
普通单文件约 450 MB
单个巨大生成数据模块约 750 MB+

也就是说:

一个数 MB / 数十万行的生成 Erlang 文件,
就可能让 ELP 额外增加数百 MB 内存。

继续将几个最大的生成数据模块放入临时目录测试,合计约数十万行,ELP RSS 直接接近 1GB+

至此基本可以坐实根因:

ELP 高内存主要由巨型自动生成 Erlang 数据模块触发。

十、为什么几 MB 源码会放大成几百 MB 内存?

源码文件本身只是文本,但 LSP / 编译器前端不会只保存文本。

ELP 需要构建很多中间结构:

源码文本
→ token stream
→ syntax tree / AST
→ module symbols
→ function clauses
→ references
→ diagnostics context
→ incremental database cache

对于普通业务模块,这个放大倍率通常可以接受。

但对数十万行生成代码来说,情况完全不同。

这类文件通常包含:

大量函数子句
大量 literal tuple/list/map
大量 AST 节点
大量位置信息
大量索引项
大量增量分析缓存

所以,一个几 MB 的文本文件,在 LSP 内部变成数百 MB 的内存占用并不意外。

关键问题不是文件大小本身,而是:

这个文件被解析后产生了多少语法节点、语义节点和索引数据。

对于 20 万行级别的生成代码,AST 和语义索引的规模会非常夸张。


十一、最终解决方案

最终选择在 ELP 的 .elp.toml 中排除生成数据目录,只索引业务源码、协议源码、系统辅助源码等。

示例:

[build_info]
apps = [
  { name = "app", dir = ".", src_dirs = ["src/core", "src/protocol", "src/system", "src/lib"], include_dirs = ["include"], deps = [], build_deps = [] }
]
deps = []
build_deps = []

[eqwalizer]
enable_all = false

[otp]
exclude_apps = [
  "megaco",
  "diameter",
  "wx",
  "observer",
  "debugger",
  "common_test",
  "eunit",
  "et",
  "reltool",
  "runtime_tools",
  "ssh",
  "ftp",
  "tftp",
  "snmp",
  "odbc"
]

调整后,ELP 从最初的:

1.3GB~1.4GB

下降到大约:

800MB

虽然 800MB 仍然不算特别轻,但已经比原来稳定很多,也进入了可接受范围。


十二、如果仍想继续降低内存

如果希望进一步降低 ELP 内存,可以继续收敛 src_dirs

例如:

src_dirs = ["src/core"]

只索引日常主要开发目录。

这样内存可能进一步下降,但代价是跨目录跳转、引用查找和补全信息会变少。

可以根据日常开发习惯做取舍:

需要完整跳转:索引更多业务目录
需要低内存:只索引当前主要开发目录
生成数据目录:尽量不要索引

十三、按需拆分生成源码目录

如果有些生成模块确实需要 LSP 支持,可以把生成源码拆成两类:

src/generated_lsp/
src/generated_heavy/

其中:

generated_lsp:小型、偶尔需要跳转的生成模块
generated_heavy:巨大生成模块,不给 LSP 索引

.elp.toml 只包含:

src_dirs = ["src/core", "src/protocol", "src/generated_lsp"]

构建系统仍然可以编译 generated_heavy,只是 LSP 不处理它。

这样可以在保留运行时结构的同时,避免 Neovim 打开项目时被巨型生成文件拖垮。


十四、长期优化方向

从长期看,更好的方式是减少巨型 Erlang 源码生成。

如果这些模块本质上只是静态配置数据,可以考虑不要生成几十万行 Erlang 函数子句,而是生成外部数据文件,例如:

.term
.bin
.json
ets dump
dets file

Erlang 侧只保留轻量查询接口,例如:

data_lookup:get(Key) ->
    persistent_term:get({data_lookup, Key}).

或者:

data_lookup:get(Key) ->
    ets:lookup_element(data_table, Key, 2).

这样做的好处是:

  1. LSP 不需要解析巨型 Erlang 源码。
  2. 编译器前端压力下降。
  3. xref / dialyzer / formatter / editor 插件都更轻。
  4. 运行时仍然可以通过 ETS / persistent_term 保持较好的查询性能。
  5. 生成数据与业务代码边界更清晰。

当然,这种改造会影响构建流程、热更方式、运行时加载方式,不一定适合短期马上做。

短期最实用的方式仍然是:

LSP 层面排除巨型生成源码。

十五、这次排查的关键经验

这次排查有几个比较有价值的经验。

1. 不要只看 RSS,要看 PSS 和 Private_Dirty

如果 PSS 和 Private_Dirty 也很高,说明不是虚高,而是真实内存占用。

2. .elpignore 有用,但不一定能解决根因

.elpignore 可以排除无关目录,但如果真正的问题文件仍然在 src_dirs 中,内存不会明显下降。

3. EqWAlizer 有成本,但不一定是主因

关闭 EqWAlizer 可以减少一部分内存,但如果存在巨型生成源码,关闭 EqWAlizer 也救不了根本问题。

4. 要用分区测试定位问题目录

不要凭感觉猜哪个目录最重。直接修改 .elp.toml,一次只索引一个目录,看内存变化,是最快的定位方式。

5. 文件数量不等于索引成本

几百个普通业务模块可能只增加一两百 MB,但一个数十万行的生成模块就可能增加几百 MB。

真正决定 LSP 成本的是:

AST 节点数量
函数子句数量
literal 数量
符号和引用数量
语义索引规模

6. 生成源码不一定适合交给 LSP

运行时喜欢的代码结构,不一定适合开发工具链。

大量 pattern matching 子句对运行时可能很好,但对 LSP 来说是巨大的分析负担。


十六、最终结论

这次 ELP 内存过高的根因不是 Rust 实现问题,也不是单纯 ELP “无故吃内存”。

真正原因是:

项目中存在巨型自动生成 Erlang 数据模块;
这些模块达到数 MB、数十万行;
ELP 对它们构建 AST 和语义索引后,内存产生高倍放大。

最终处理方式:

业务源码交给 ELP;
巨型生成源码排除出 LSP;
必要时拆分小型生成模块;
长期考虑改为外部数据文件或运行时加载结构。

这次优化后,ELP 常驻内存从约 1.3GB~1.4GB 降到约 800MB,问题基本可控。

对于类似 Erlang 项目,如果 ELP、erlang_ls、dialyzer、xref 等工具出现异常高内存或异常慢,建议优先排查:

是否存在巨大自动生成 .erl 文件
是否存在几十万行级别的函数子句
是否存在大量 literal 数据写在源码中
是否可以从 LSP 索引中排除这些文件

很多时候,问题不在 LSP 本身,而在“把数据生成成源码”这件事对开发工具链并不友好。