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这里有几个注意点:
src_dirs只应该放真正需要 LSP 索引的源码目录。include_dirs只应该放头文件目录。- 不要把生成代码目录、运行数据目录、构建产物目录误放进
src_dirs。 - 某些 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 fileErlang 侧只保留轻量查询接口,例如:
data_lookup:get(Key) ->
persistent_term:get({data_lookup, Key}).或者:
data_lookup:get(Key) ->
ets:lookup_element(data_table, Key, 2).这样做的好处是:
- LSP 不需要解析巨型 Erlang 源码。
- 编译器前端压力下降。
- xref / dialyzer / formatter / editor 插件都更轻。
- 运行时仍然可以通过 ETS / persistent_term 保持较好的查询性能。
- 生成数据与业务代码边界更清晰。
当然,这种改造会影响构建流程、热更方式、运行时加载方式,不一定适合短期马上做。
短期最实用的方式仍然是:
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 本身,而在“把数据生成成源码”这件事对开发工具链并不友好。