Thread progress 是 Erlang 虚拟机中负责无锁同步的基础设施。它在文档中的描述初看有些抽象，比如“线程返回到一个与任何其他代码都无关的已知状态”。但结合载体迁移等具体场景后，各个概念会逐渐变得清晰。一、Thread Progress 要解决的两类问题多线程环境中有两个常见的麻烦。麻烦一：如何知道所有线程都用完了某个共享结构？要释放一个全局数据结构，必须确认没有任何线程还在访问它。最直接的办法是引用计数：每次访问加一，退出减一，归零后释放。问题是计数器所在的缓存行会被所有参与的 CPU 核心争抢，产生严重的缓存乒乓。访问越频繁，扩展性越差。我们需要一种读路径开销更低的跟踪手段。补充：什么是缓存乒乓？缓存乒乓（Cache Ping-Pong）是指多核 CPU 中同一个缓存行被多个核心反复争抢修改，导致它在不同核心的私有缓存之间来回传输，像乒乓球一样。引用计数每次访问都要原子修改计数器，该计数器所在缓存行就会在所有核心间“乒乓”，严重拖慢性能。Thread progress 正是为了避免这种热路径上的争用。麻烦二：无锁编程中的内存可见性现代硬件都会对内存访问重排序，只是程度不同。使用锁时，加锁/解锁自带的屏障让重排对程序员透明。但无锁算法必须主动插入硬件内存屏障来保证顺序，这些指令开销不小，且要尽量减少使用次数。Thread progress 的设计目标，正是同时应对这两件事：以可控的开销判断“所有线程均已离开临界区”，并在这个过程中自然提供必要的屏障，支撑无锁发布与回收。二、Thread Progress 的基本思路Erlang 虚拟机将需要追踪的线程称为 managed threads（受管线程），主要是调度器线程，它们会在运行循环中频繁汇报进度。那些可能长时间阻塞、无法频繁汇报的线程（如异步 I/O 线程）属于 unmanaged threads（非受管线程）。核心机制是这样：发起一次 thread progress 操作，然后等待所有受管线程在该操作期间内都经历过两个特定事件。一旦确认，就可以安全地推导内存状态。两个事件是理解整个机制的关键。事件一：返回到一个独立的“已知状态”文档中对这个事件的定义是：线程从其他代码返回到 thread progress 功能中的一个已知状态，该状态独立于任何其他代码。拆开来看，主干就是：线程返回到一个已知状态。这个状态位于调度器线程的主循环中，是一个固定的“打卡点”。当调度器在执行业务代码、GC 或其他运行时逻辑时，它游离在 thread progress 的视野之外；只有跳回这个打卡点，才算退出了可能访问受保护数据结构的上下文。此时我们可以认为该线程不再持有对之前共享数据的任何引用。这个状态与具体业务逻辑完全解耦。补充：调度器主循环是什么？一个调度器通常对应一个 OS 线程。调度器线程反复执行一段核心循环代码（while 循环），从运行队列取进程、执行、处理 GC/信号等，然后回到循环顶部。这个循环顶部就是“已知状态”。当线程回到这里，它已不执行任何业务代码，不再持有之前共享数据的引用，是天然的“安全打卡点”。事件二：执行了一次全内存屏障每个受管线程在汇报进度时，都会执行一条全内存屏障。它保证此前所有内存写入全局可见，且后续读取不会穿越到屏障之前。这为无锁发布提供了同步基础。补充：内存屏障的作用全内存屏障是一堵“墙”：屏障之前的所有内存访问必须完成，之后的内存访问不能提前。它防止编译器和 CPU 对内存操作重排序。在 thread progress 中，线程回到安全点后、更新进度之前执行屏障，确保它之前对共享数据的写入真正刷到全局可见，之后读到的进度值不会被重排到屏障之前。两个事件必须有序地发生在相关内存操作之间。发起操作时，调用者得到一个进度值，然后轮询等待该值达成。在此期间，每个受管线程必须在发起之后、完成之前至少各经历一次事件一和事件二。通过共享内存上的通信，系统可以确信，操作完成后某些内存修改对所有受管线程已一致可见。三、与载体迁移的呼应：从逻辑删除到物理释放将 thread progress 放到载体迁移（carrier migration）的场景里，其作用会变得非常具体。载体生命周期中有一个典型动作：某个载体从全局无锁池中被取出（逻辑删除），但不能立刻 munmap 物理释放。因为可能有另一个线程在此前的遍历中已经拿到了该载体的指针，正在使用。如果直接释放，就是野指针。Thread progress 充当了从逻辑删除到物理释放之间的“安全等待期”。一条典型的时间线如下：T=100 线程 B 进入遍历载体池的临界区。此时线程 B 的进度值为 100（即它上次更新进度后尚未再次更新）。它获得了载体 X 的指针，可能继续使用它。 T=101 线程 A 将 X 从池中逻辑删除，并为 X 打上回收标签 tag = 100。这个 tag 的含义是：所有进度值 <= 100 的线程都可能持有 X 的指针，因此 X 暂时不能释放。 T=102 线程 B 完成遍历，退出临界区，逻辑上不再持有 X 的指针。 T=103 线程 B 回到调度器主循环的安全点（事件一），执行全内存屏障（事件二），然后更新自己的进度值，使其从 100 变为 101。 与此同时，线程 A（发起回收操作的线程）一直在轮询所有受管线程的进度值，例如在 erts_thr_progress_wait(100) 中循环检查。当线程 A 观察到所有线程的进度都已超过 100 时，它确认安全条件达成：所有在 tag=100 之前可能持有 X 指针的线程，都已经退出了临界区并完成了内存发布。 T=104 线程 A 确认全局进度已越过 100，于是 X 可以被安全回收或重新入池。补充：谁在轮询？这里明确是发起操作的线程 A 在轮询，而不是一个独立的“系统线程”。线程 A 调用了类似 erts_thr_progress_wait(tag) 的函数，内部循环检查所有受管线程的进度值，直到全部超过 tag。整个过程中，线程 B 读取载体指针时不需要任何原子操作或屏障。它只管读，退出临界区后正常汇报进度。后台的 thread progress 批量确认“所有人都已离开”。这与引用计数的取舍可以这样对比：方案读路径开销回收时机引用计数每次取得/释放引用都需原子修改计数器，读路径开销大引用归零立即释放thread progress读路径零开销回收会延迟，需等待宽限期在高并发读多写少的场景下，这种置换非常划算。载体迁移正是靠这一点，才敢在调度器的高频路径上使用无锁池。四、两种经典使用模式按照两个事件的性质，thread progress 支持两种典型用法。模式一：基于事件一的无引用计数释放假设功能 F 使用数据结构 D，线程进入 F 时获取 D 的引用，离开前放弃。如果先撤掉全局能找到 D 的入口（例如将指针置空，或切换到新版本），然后等待所有受管线程都经历事件一，就可以安全释放旧 D。事件一的“返回已知状态”正是线程离开 F 代码的标志。这个模式和前面载体回收的逻辑完全一致。模式二：基于事件二的复杂修改无锁发布写者先在私有区域准备好新数据，执行一条全屏障，然后等待所有受管线程都经历事件二（即各自的全屏障），最后才把新数据指针发布到全局可见位置。此后，任何受管线程读取这块内存时无需额外屏障，就能看到一致的修改。读端不需要屏障的原因在于，每个线程在上一次汇报进度时已经执行过全屏障，那个屏障顺便充当了读端的同步点，很接近 RCU 的读端无锁模式。载体池的无锁双向链表操作（插入和获取采用不同遍历方向）之所以能够正确并发，也依赖了这种内存顺序保证。五、实现中的几个关键决策Thread progress 使用一个全局计数器，在所有受管线程都汇报进度后递增。为避免热点，每个线程在专属缓存行里写确认标志，由一个动态选出的领导线程（leader）统一读取并推动全局计数。通信方向是领导广播，其他线程点对点回复，跨核流量很低。erts_thr_progress_later() 返回的值是当前已知全局值加 2。这是因为在调用时，其他线程可能已经确认了加 1，但不可能已经确认加 2。等待这个值就能可靠保证操作期间所有线程都更新过进度，即经历了两个事件。补充：为什么加 1 可能已发生，加 2 不可能？全局进度要推进 1，需要所有受管线程都更新本地进度超过当前值。假设当前全局值为 G，调用者读到 G，但可能其他线程刚刚完成了更新，使实际全局值变为 G+1。所以读到 G 时可能已落后一步。但全局要推进到 G+2，需要所有线程（包括调用者自己）都更新超过 G+1，而调用者尚未更新，所以不可能已经达到 G+2。等待 G+2 迫使所有线程即使已完成 G+1，也必须再完成一轮更新，从而确保每个线程都至少经历了一次事件一和事件二。非受管线程偶尔也要访问受保护数据，但它们不能频繁汇报进度。它们通过 unmanaged_delay 临时阻止进度推进，给自己开一个安全窗口，操作完后用 unmanaged_continue 解除。实现上用了两个引用计数器（current 和 waiting）防止永久阻塞。延迟只阻止下一次全局递增，尽量减少干扰。但频繁调用 delay/continue 会把计数器缓存行变成新热点，因此文档强调要谨慎使用。补充：非受管线程延迟机制的细节非受管线程进入临界区时递增 current 计数器，并获得一个句柄。退出时用句柄递减之前递增的那个计数器。Leader 线程在递增全局进度前检查 waiting 是否为零；若为零则交换 current 和 waiting，然后递增全局计数器。交换后，新的 waiting 记录的是交换前 current 中的活跃线程数，这些线程退出时会递减它；新的 current 继续接收新的延迟请求。这样设计避免单计数器被不同线程持续占用的活锁，且延迟只影响第二次全局递增，减少干扰。在载体迁移中，异步 I/O 线程就是通过这套接口安全地短暂持有载体引用，而无需变成受管线程。六、看得见的收益ETS 表原本用引用计数管理主结构生命周期，每次操作都在热路径上争抢计数器。后来改为删除时向每个调度器发送“确认删除”作业来回收，虽然缓解了热路径压力，但删除操作本身需要分配与调度器数量相等的作业块，代价不低。引入 thread progress 后，只需调度一个 thread_progress_later 回调来完成延迟释放。代码大幅简化，在四核机器上跑 Mnesia TPCB 基准测试，每秒事务数提升超过 10%。补充：ETS 源码中的具体印证在 Erlang/OTP 源码中，erl_db.c 的 schedule_free_dbtable 函数是 ETS 释放表结构的入口。它调用 erts_schedule_thr_prgr_later_cleanup_op(free_dbtable, (void *) tb, &tb->release.data, sizeof(DbTable))，将 free_dbtable 注册为延迟回调。erts_schedule_thr_prgr_later_cleanup_op 是 erts_schedule_thr_prgr_later_op 的变体，会在回调执行后自动释放 ErtsThrPrgrLaterOp 所在的内存块（此处是整个 DbTable）。ErtsThrPrgrLaterOp 结构体被直接嵌入到 DbTable（通过 tb->release.data）和 DbTableCATreeNode（ordered_set 树的节点，字段名 free_item）中，避免了额外内存分配。其他如 persistent_term、端口、追踪器也使用了相同机制。补充：澄清“删除表”与“删除记录”Thread progress 用于删除整张 ETS 表时释放表的主结构，而不是删除表中的某一条记录。删除单条记录由表级锁或分段锁保护，不涉及表结构的生命周期，不需要 thread progress。只有当整个表被删除，需要回收表结构内存时，才使用 thread progress 安全等待。载体迁移同样受益于这一改造。原本复杂的确认作业机制被统一基础设施替代，一个载体从池中移除后，注册一个回调，thread progress 在安全时机自动触发回收。随着更多内部组件（进程表、端口表等）迁移到这套机制，系统总体的缓存一致性通信反而下降：原本分散的锁、引用计数、独有屏障被统一的宽限期检查均摊和消解。补充：ETS 分配器与 thread progress 的关系ERTS 中有一个专门的 ets_alloc 分配器，通常每个调度器（CPU 核心）一个实例，与调度器线程绑定，以减少分配路径上的锁竞争。ets_alloc 负责实际的内存分配/释放，而 thread progress 负责确定“何时释放才安全”。两者协同：逻辑删除表后，thread progress 延迟到安全时机，再调用分配器的释放函数归还内存。七、API 概览（对话补充新增）Thread progress 对外暴露的核心 API 包括：API说明ErtsThrPrgrVal erts_thr_progress_later(void)发起操作，返回当前全局进度 + 2 的目标值。int erts_thr_progress_has_reached(ErtsThrPrgrVal val)非阻塞检查是否已达到目标进度。void erts_thr_progress_wakeup(ErtsSchedulerData *esdp, ErtsThrPrgrVal val)请求在进度达到 val 时唤醒调用线程（阻塞等待）。int erts_thr_progress_update(ErtsSchedulerData *esdp)受管线程更新自己的进度，若返回非零需调用 leader_update。int erts_thr_progress_leader_update(ErtsSchedulerData *esdp)领导线程推进全局进度。ErtsThrPrgrDelayHandle erts_thr_progress_unmanaged_delay(void) / void erts_thr_progress_unmanaged_continue(ErtsThrPrgrDelayHandle handle)非受管线程延迟/恢复进度。void erts_schedule_thr_prgr_later_op(void (*funcp)(void *), void *argp, ErtsThrPrgrLaterOp *memp)注册回调，进度达成后自动执行。另有 _cleanup_ 变体自动释放内存。使用 later() + has_reached() 可以轮询；使用 wakeup() 可以阻塞等待；使用 schedule_thr_prgr_later_op() 可以异步回调，适合发起后继续工作的场景。八、总结Thread progress 是一种基于宽限期的无锁同步框架。它用定期汇报进度加分布式确认的方式，把昂贵的内存屏障开销固定化，同时向上提供“等待所有线程离开临界区”和“无锁发布”两种原语。它与载体迁移的关系可以这样看：载体迁移通过所有权与经营权分离、无锁池、方向分离搜索和自属载体优先等策略实现了内存的高效流转与 NUMA 本地性；而 thread progress 是这些机制安全运转的基石。它让跨线程的内存回收不必引入引用计数热点，使读取路径完全无锁。搞清楚两个事件的真正含义，再看到它们在载体生命周期时间线里的精确对应，thread progress 就不再是一堆抽象概念，而是一把量尺：量出线程们走到了哪里，告诉你什么时候可以安心处理旧资源
Erlang 载体迁移机制解析：从无锁池设计到 NUMA 架构的高效适配
1. 背景：Erlang 多核内存分配的问题Erlang/OTP 虚拟机（ERTS）的每个调度器（Scheduler）线程都绑定独立的分配器实例（Allocator Instance），以消除全局内存分配锁。每个分配器通过 mmap 向操作系统申请大块连续内存，称为载体（Carrier），并在内部将载体切割为小块（Block）供 Erlang 进程使用。这种设计在并发性能上具有优势，但在内存使用峰值过后会产生一个问题：某分配器可能持有大量利用率极低的多块载体（仅少数块被占用），而其他分配器却因缺乏可用块而不得不申请新的载体。其结果是系统总体内存需求已经下降，载体数量与物理内存占用（RSS）却不降反增。2. 载体迁移机制的引入为解决上述问题，ERTS 引入了载体迁移（Carrier Migration）机制，允许低利用率的多块载体在不同分配器实例之间流动复用。2.1 所有权（Owner）与经营权（Employer）分离载体迁移的核心是“所有权与经营权分离”。每个载体包含两个角色：+----------------------------------------------------+ | Carrier（一块 mmap 内存） | | | | Owner（所有者）：创建该载体的分配器实例 | | - 永久不变 | | - 唯一有权执行 munmap 归还内存 | | | | Employer（雇主）：当前管理内部块的分配器实例 | | - 可随迁移改变 | | - 负责日常分配（allocate）与释放（deallocate） | +----------------------------------------------------+Owner：创建载体的分配器实例，生命周期内不变，负责最终内存释放。这保证了释放操作无需跨线程同步，且在 NUMA 系统上载体物理归属节点不会混淆。Employer：实际使用载体的分配器实例，可变更。当雇主发现载体利用率低于阈值，可将其“放弃”至全局池；其他急需内存的分配器可从池中“获取”并成为新雇主。2.2 载体池的无锁实现供迁移使用的全局载体池（Carrier Pool）是一个无锁环形双向链表。池中包含一个哨兵节点（Sentinel）作为边界标记。池为空时，哨兵的 next 与 prev 均指向自身。Head（原子指针，可动态变化） | v +-----------+ +-----------+ +-----------+ | Sentinel | <-> | MBC A | <-> | MBC B | <-> ...（环形） | （哨兵） | | （真实载体）| | （真实载体）| +-----------+ +-----------+ +-----------+每个池中载体都包含 next / prev 指针及状态标记（IN_POOL、BUSY、HOMECOMING）。为降低高并发下的竞争，池操作采用两项策略：跳过第一个节点：插入或获取时，总是从 Head 指针所指节点的下一节点开始操作，避免所有线程集中修改第一个节点，消除缓存行弹跳热点。方向分离：插入操作沿 next 指针顺时针方向遍历，倾向于在靠近哨兵的尾部插入；获取操作沿 prev 指针逆时针方向遍历，从远离哨兵的另一端开始查找。 Head | v +---------+ +----| MBC A |----+ | +---------+ | | prev next | v v 获取方向（逆时针） 插入方向（顺时针） | | v v +---------+ +---------+ | MBC C | | MBC B | +---------+ +---------+ \ / +-------+--------+ v +-----------+ | Sentinel | （插入倾向尾部） +-----------+线程安全通过原子操作保证，指针修改遵循“先 next 后 prev”的顺序以避免死锁。被移出池的载体还需要经过线程进度机制的延迟回收，确保所有可能引用它的线程完成后，才允许其重新入池或释放。2.3 线程进度（Thread Progress）：安全回收的“时钟锁”无锁链表中的载体被从池中移除后，只是完成了逻辑删除，不能立刻执行 munmap。原因是其他线程可能在先前的遍历中已经读取到该载体的指针；若此时释放内存，后续解引用将导致野指针访问。ERTS 的线程进度（Thread Progress）机制可将此类问题理解为一种基于 epoch / generation 的宽限期判断：参与遍历的线程在关键区间报告自身进度；当系统确认所有相关线程都已越过某个进度点时，在该点之前可能持有的旧指针便不再有效，可以安全回收对应载体。T = 100 | |-- [线程 B] 开始遍历池子，记录本地进度 = 100，并取得 Carrier X 的指针 | |-- [线程 A] Fetch 取得 Carrier X（逻辑删除） | └── 为 X 记录回收标签：tag = 100 | └── 此时不能立即销毁或重新发布 X | |-- [线程 B] 结束遍历，不再持有 X 的指针，并推进本地进度 | |-- [系统] 确认所有可能处于 T = 100 的线程均已离开该区间 | |-- [线程 A / Owner] 当前进度已越过 tag | └── 可以安全地重新入池，或执行 munmap v这里的核心不是让每一次指针访问都修改共享计数，而是通过进度快照批量确认“旧读者已经离场”。这与引用计数的取舍如下：方案优点代价引用计数直观，能即时得知对象是否仍被引用每次取得或释放引用都需要原子更新，易造成缓存行争用线程进度 / Epoch读路径通常只需进入、离开受保护区间，适合批量延迟回收回收会延后，必须等待宽限期结束ERTS 选择线程进度机制，正是为了避免高并发读路径上的频繁原子增减操作；它是 Carrier 跨线程迁移后能够安全回收的最后一道保障。3. 旧算法的 Bad Cluster 问题在 OTP 17.4 之前，所有获取操作均从哨兵节点开始顺序扫描。旧算法：所有线程从 Sentinel 开始搜索 Head（固定指向 Sentinel） | v +------------------------------------------------------+ | Sentinel <-- 坏载体 1（碎片多） | | <-- 坏载体 2（碎片多） | | <-- 坏载体 3（碎片多） | | <-- ... 达到搜索限制 | +------------------------------------------------------+ | v 搜索配额耗尽，分配失败 -> 强制 mmap +-----------+ | 好载体 | 被埋在坏集群后面，无法取到 +-----------+当大量碎片化严重、空闲块极小的“低质载体”因利用率低被不断放入池中时，它们逐渐聚集在哨兵附近，形成坏集群（Bad Cluster）。由于每次搜索都必须先遍历这些无法满足需求的载体，当集群长度超过搜索限制时，所有搜索均失败。调度器被迫分配新载体，这些新载体之后又可能再次成为低质载体入池，形成恶性循环，最终内存耗尽。本质上，这是一个固定入口点导致的遍历阻塞问题。4. 新算法的改进：自属载体优先与多入口搜索OTP 17.4 引入私有池树（pooled_tree）和多入口策略来解决坏集群问题。私有池树：每个分配器维护一棵 pooled_tree，仅存储由自己创建（即 Owner）且已放弃到池中的载体。载体被放弃时，若 Employer 正是 Owner，直接加入；否则通过延迟释放队列先归还 Owner 再加入。搜索优先级：搜索自己的 pooled_tree，优先复用历史自属载体。若 pooled_tree 中没有满足请求的块，但存在自己的载体，则以该载体为入口进入全局链表搜索，避免从哨兵开始。最后才考虑复活已标记为待释放的空载体。新算法：每个调度器用自己的旧载体作为入口 核 A 入口 --> [坏载体 A]（A 自己扔的） ---> ... 核 B 入口 --> [坏载体 B]（B 自己扔的） ---> ... 核 C 入口 --> [好载体 C]（直接命中） 全局池链表： ... <-> [载体] <-> [载体] <-> [Sentinel] <-> ... ^ ^ | | 核 B 入口 核 C 入口该策略使每个分配器拥有独立的链表入口点，分散了搜索起点，有效规避了坏集群堵塞，同时减少了全局池上的竞争。5. 为何该算法在 NUMA 架构上性能提升更为显著以上改进解决了功能性缺陷（坏集群），但文档特别指出：we prefer carriers created by the thread itself, which is good for NUMA performance。要理解这一点，需要先了解 NUMA 架构与 UMA（SMP）架构的区别。5.1 UMA 与 NUMA 简述UMA（统一内存访问）架构中，所有处理器核心共享同一个内存控制器，访问任意内存位置的延迟完全相同。典型系统为单路 CPU。+------+ +------+ +------+ +------+ | Core | | Core | | Core | | Core | +--+---+ +--+---+ +--+---+ +--+---+ | | | | +---------+---------+---------+----> 统一内存控制器 --> [内存]NUMA（非统一内存访问）架构中，系统划分为多个节点，每个节点包含一组核心及其本地内存。访问本地内存延迟低，访问远端内存需通过片间互连总线。+-------------------+ 互联总线 +-------------------+ | 节点 0 | <===========> | 节点 1 | | +---+ +---+ | | +---+ +---+ | | | C0| | C1| ... | | | C4| | C5| ... | | +---+ +---+ | | +---+ +---+ | | [本地内存 0] | | [本地内存 1] | +-------------------+ +-------------------+ 访问本地快；访问远端慢 访问本地快；访问远端慢5.2 算法在 NUMA 上的高效原理在 NUMA 感知的 Erlang VM 中，调度器线程被绑定到特定 NUMA 节点的核心上。当调度器调用 mmap 创建载体时，操作系统会优先从其所在节点分配物理内存，因此一个调度器自属的载体，物理上通常位于其本地内存。新算法的搜索优先级带来以下效果：优先复用本地内存：优先使用 pooled_tree 中的自属载体，或以自属载体作为全局池入口，可提高复用本地内存的概率，使数据访问更接近低延迟、高带宽的本地节点。避免远端访问的随机惩罚：若无此优先策略，调度器可能随机获取到其他节点创建的载体（远端内存），导致高并发下执行路径中混杂高延迟访问，吞吐量下降，尾延迟恶化。UMA 与 NUMA 的收益差异：在 UMA 系统上，所有内存延迟一致，复用自属载体主要提供分散入口，解决坏集群这一功能性缺陷，并无显著的本地性性能红利。在 NUMA 系统上，该策略不仅解决了坏集群，更关键的是提升本地节点内存的复用率，减少跨节点远端访问带来的结构性性能损耗。6. 完整生命周期流程一个载体的完整生命周期可概括为：创建：调度器 A（Owner）通过 mmap 申请本地物理内存，成为载体 X 的所有者。经营：A 作为初始 Employer，从 X 中切分小块供使用。放弃：当利用率低于阈值，A 将 X 从自己的空闲块搜索树中摘除，插入全局载体池及自己的 pooled_tree（若当前 Employer 非 Owner，则通过延迟释放队列回传给 Owner）。获取：调度器 B 按优先级搜索：先查自己的 pooled_tree，再以自属载体为入口搜索全局池，最后才考虑待释放载体。获取后 B 成为新 Employer。延迟回收：X 从池中移除或转交时，记录线程进度标签；在相关线程全部越过该标签前，X 不得重新发布或物理释放。释放：当载体完全为空，Owner（A）在确认线程进度宽限期结束后，调用 munmap 归还操作系统。7. 分配策略与性能权衡载体迁移要求分配器使用分离式空闲块管理的策略，目前实现的有 aoff、aoffcaobf、aoffcbf。它们在支持迁移的同时会带来一定的数据结构维护开销，其中 aoffcbf 是性能损失较小的折中方案。对延迟极度敏感且无需迁移的场景，仍可使用传统的 gf 或 bf 策略，但可能牺牲内存回收能力。8. 总结Erlang 的载体迁移机制由所有权与经营权分离、无锁环形池、方向分离并发控制、自属载体优先的多入口搜索，以及线程进度驱动的延迟回收共同构成。前几项机制使载体能够高效流动、复用并保持 NUMA 本地性；线程进度则确保被移除的载体只有在所有潜在读者离开后才能重新发布或释放。它在解决多核环境下内存复用与回收问题的同时，也将软件层面的复用逻辑映射到硬件的本地节点上，从而在 NUMA 系统上获得更明显的性能收益。
双重哈希：从开放寻址到 Bloom Filter 工程实践一、问题来源某在线服务维护了一个规模较大的持久化键集合，最初的实现会在进程启动时把全部记录加载到内存索引中。随着历史数据增长，这种方式逐渐暴露出两个问题：大量低频数据长期占用内存；某些内部流程会反复查询不存在的键，造成大量无效持久化读取。优化后的结构分为三层：热点缓存 ↓ miss Bloom Filter ↓ possibly present 持久化存储查询流程如下：热点缓存命中 → 直接返回 热点缓存未命中，Bloom Filter 返回 false → 确定不存在，不访问持久化存储 热点缓存未命中，Bloom Filter 返回 true → 查询持久化存储 → 命中后写入热点缓存Bloom Filter 不是权威数据源，也不能替代持久化存储。它只负责快速判断一个键是否一定不存在。引入 Bloom Filter 后又出现了一个计算问题：一次插入或查询需要生成 k 个位图索引，如果为每个索引执行一次完整哈希，高频路径上的 CPU 成本会随 k 增长。开放寻址哈希表中也有一个相似问题：发生冲突后必须继续寻找其他槽位，但固定步长容易形成聚集，选择不当还可能只访问到部分槽位。双重哈希（Double Hashing）的价值正在这里：只计算两个基础哈希值，再用简单的整数运算生成一组分散的位置。它在 Bloom Filter 中减少完整哈希次数，在开放寻址哈希表中生成冲突后的探测路径。两种场景使用相同的数学形式，但解决的问题和约束并不完全相同。下面先从这个共同解法入手，再分别说明它为什么有效，最后回到上述服务的工程落地。二、共同解法：起点加步长为了用较低的计算成本生成足够分散的位置，双重哈希只计算两个基础哈希值，然后用一个线性关系生成位置序列：p_i(x) = (h_1(x) + i × h_2(x)) mod m其中：x：待处理的元素；h_1(x)：第一个哈希值，决定序列起点；h_2(x)：第二个哈希值，决定序列步长；i：序号，从 0 开始；m：可用位置的总数。当 i 依次取 0、1、2…… 时，得到：p_0(x) = h_1(x) mod m p_1(x) = (h_1(x) + h_2(x)) mod m p_2(x) = (h_1(x) + 2 × h_2(x)) mod m ...可以把它理解为：第一个哈希决定从哪里开始 第二个哈希决定每次向前走多远不同元素不仅起点可能不同，步长也可能不同，因此生成的位置序列通常比固定步长更分散。2.1 递增计算直接使用公式时，每一轮都要计算 i × h_2(x)。实际实现可以使用递增方式：position_0 = h1 position_1 = position_0 + h2 position_2 = position_1 + h2 ...每次递增后对 m 取模即可，从而把乘法转换为加法。2.2 步长为零的问题如果：h_2(x) = 0那么所有位置都相同：p_i(x) = h_1(x) mod m因此工程实现通常会把零步长修正为一个非零值。三、开放寻址哈希表中的双重哈希3.1 哈希冲突哈希表通过哈希函数将键映射到槽位：index = h(x) mod m不同键可能得到相同槽位，这就是哈希冲突。开放寻址法不使用额外链表保存冲突元素，而是在哈希表内部继续寻找其他槽位。常见探测方式包括：线性探测；二次探测；双重哈希。双重哈希的探测序列为：index_i = (h_1(x) + i × h_2(x)) mod m查询或插入时，从 i = 0 开始依次探测，直到：找到目标键；找到可插入的空槽；或者确认探测失败。3.2 示例假设哈希表长度为 7：h_1(x) = x mod 7 h_2(x) = 5 - (x mod 5)依次插入 14、21、28。插入 14h_1(14) = 0槽位 0 为空，直接插入。插入 21初始位置仍然是 0，发生冲突：h_2(21) = 4第一次探测：(0 + 1 × 4) mod 7 = 4槽位 4 为空，将 21 插入槽位 4。插入 28初始位置同样是 0：h_2(28) = 2第一次探测：(0 + 1 × 2) mod 7 = 2槽位 2 为空，将 28 插入槽位 2。最终分布如下：槽位元素014228421三个键具有相同起点，但第二个哈希值不同，因此沿不同路径探测。3.3 为什么步长要与表长互质开放寻址哈希表通常要求：gcd(h_2(x), m) = 1这样可以保证探测序列在重复前遍历所有槽位。取模将每个探测位置限制在 [0, m - 1] 中，而步长与表长的最大公因数决定序列能覆盖其中多少个位置。令步长为 s = h_2(x)。探测序列中的两个位置 p_i 和 p_j 相同，当且仅当：(i - j) × s mod m = 0也就是 (i - j) × s 能被 m 整除。设：g = gcd(s, m)那么序列回到起点前的周期为：period = m / g因此：当 g = 1 时，周期为 m，可以访问全部 m 个槽位；当 g > 1 时，周期小于 m，只能访问部分槽位。例如，表长为 8、步长为 2 时：0 → 2 → 4 → 6 → 0该序列只能访问一半槽位。即使其他槽位仍然为空，插入过程也无法到达。如果步长改为 3：0 → 3 → 6 → 1 → 4 → 7 → 2 → 5 → 0由于 3 和 8 互质，因此能够遍历全部槽位。质数的特殊作用质数 p 只有 1 和 p 两个正因数。因此，如果表长 m 是质数，那么任意满足以下范围的步长：1 ≤ s < m都必然与 m 互质，也就必然产生长度为 m 的完整探测周期。这是双重哈希中使用质数的真正原因：它用一个简单的范围约束，替代了对每个步长单独计算最大公因数。这里容易混淆的是：真正的条件是步长与表长互质，而不是步长本身必须是质数。表长 m = 8、步长 s = 3：3 是质数且与 8 互质，可以遍历全表；表长 m = 8、步长 s = 5：同样可以遍历全表；表长 m = 15、步长 s = 3：虽然 3 是质数，但它是 15 的因数，周期只有 15 / 3 = 5；表长 m = 7、步长 s = 4：虽然 4 不是质数，但它与 7 互质，仍可遍历全表。因此常见设计是：表长取质数，并把第二个哈希结果限制在 [1, m - 1]；表长取 2 的幂，并强制步长为奇数；表长为一般合数时，显式保证或检查 gcd(s, m) = 1。同样的周期规律也适用于有固定间隔的键。若键为 A + i × d，对 m 取模后的周期也是：m / gcd(d, m)当 m 是质数时，只要 d 不是 m 的倍数，这些余数就会遍历全部槽位。这解释了为什么质数模数对等差、倍数、对齐等规律输入通常更稳健。但质数并不能弥补质量很差的基础哈希函数；工程上仍需要让 h_1 和 h_2 充分混合输入信息。3.4 表长为 2 的幂如果表长满足：m = 2^r那么任意奇数都与 m 互质。因此可以把第二个哈希值修正为奇数：normalize_step(Value, Modulus) -> case (Value bor 1) rem Modulus of 0 -> 1; Step -> Step end.同时，模运算可以转换为位与：hash mod m等价于：hash & (m - 1)但这个优化只在 m 确实为 2 的整数幂时成立。长度按某个块大小对齐，并不代表总长度一定是 2 的幂。3.5 优缺点双重哈希的优点：不同键通常具有不同的探测步长；能缓解线性探测的一级聚集；相比二次探测，具有相同起点的键不容易共享探测路径；在较高负载因子下通常比简单线性探测稳定。它的代价包括：需要计算两个基础哈希值；跳跃访问的缓存局部性较差；删除通常需要墓碑标记；步长设计不当时可能无法遍历整个表。四、Bloom Filter 中的双重哈希4.1 Bloom Filter 的基本原理Bloom Filter 是一种空间效率较高的概率型集合结构，由以下部分组成：一个长度为 m 的位图；k 个索引位置；插入和查询操作。插入元素时，将对应的 k 个位置设置为 1。查询元素时，检查这些位置是否全部为 1。判断结果具有以下语义：存在任意一个 0：元素一定不存在 所有位置均为 1：元素可能存在因此，Bloom Filter 允许假阳性（false positive），但正常实现不应产生假阴性（false negative）。4.2 多次完整哈希的成本理论描述通常假设 Bloom Filter 使用 k 个相互独立的哈希函数：f_1(x), f_2(x), ..., f_k(x)如果每次插入或查询都执行 k 次完整哈希，哈希计算本身可能成为高频路径上的开销。双重哈希只计算两个基础哈希值：a = h_1(x) b = h_2(x)然后派生出 k 个位置：g_i(x) = (a + i × b) mod m其中：i = 0, 1, ..., k - 1这样，一次操作只需要：2 次完整哈希；k 次简单整数运算。4.3 参数计算设：n：预计插入的元素数量；m：位图长度；k：哈希次数；b：平均为每个元素分配的位数。可以先估算位图长度：m ≈ n × b理论上较合适的哈希次数约为：k = (m / n) × ln(2)工程实现通常会对 k 取整，并设置合理的上下限，避免异常容量导致单次操作计算过多索引。Bloom Filter 的假阳性概率近似为：p ≈ (1 - e^(-k × n / m))^k增加位图长度通常可以降低误判率，但会增加内存占用；增加哈希次数只在一定范围内有效，超过理论最优值后反而可能降低效率。4.4 Bloom 场景是否要求步长互质开放寻址哈希表可能需要遍历全部槽位，因此通常要求步长与表长互质。Bloom Filter 只使用序列中的前 k 个位置，不需要遍历完整位图。因此，步长与位图长度互质并不是正确性的硬性条件。不过，如果两者存在较大的公因数，可能造成：派生位置更容易重复；实际有效哈希次数减少；位分布质量下降；假阳性概率升高。因此仍应避免零步长，并尽量选择分散性较好的步长。将步长修正为奇数是一种成本很低的工程手段。如果位图长度是 2 的幂，奇数步长可以保证互质；如果位图长度只是按块对齐，则奇数步长并不能严格保证互质。五、工程落地前文的问题背景给出了整体查询结构，本章继续说明 Bloom Filter 的构建、存储和正确性处理。5.1 启动构建服务启动时遍历持久化数据，完成以下工作：统计实际元素数量；根据元素数量计算位图长度；计算哈希次数；将已有键加入 Bloom Filter；只把真正的热点记录预热到内存缓存。这样可以避免把全部业务数据常驻内存，同时保留对不存在键的快速拦截能力。如果启动时元素数量为 0，也不应创建长度为 0 的位图。通常需要使用一个合理的默认容量，以便运行期新增元素仍然能够写入过滤器。5.2 使用双重哈希生成索引下面是一段经过泛化处理的 Erlang 风格伪代码：base_hashes(Item, BitCount) -> First = erlang:phash2({seed_a, Item}, BitCount), RawStep = erlang:phash2({seed_b, Item}, BitCount), Step = normalize_step(RawStep, BitCount), {First, Step}. normalize_step(Value, Modulus) -> case (Value bor 1) rem Modulus of 0 -> 1; Step -> Step end. position(First, Step, Round, BitCount) -> (First + Round * Step) rem BitCount.插入或查询时，只调用一次 base_hashes/2，得到两个基础哈希值：{First, Step} = base_hashes(Item, BitCount), Positions = [ position(First, Step, Round, BitCount) || Round <- lists:seq(0, HashCount - 1) ].两个 salt 用于区分两次基础哈希的输入域，不需要包含任何业务信息。5.3 分块位图如果使用一个很大的二进制保存整个位图，那么修改其中一个位可能需要重新构造较大的二进制。一种更灵活的方案是将位图切分成固定大小的块：完整位图 ├── block 0 ├── block 1 ├── block 2 └── ...这些块可以按需保存在 Map 中：#{ BlockNumber => BlockBinary }给定一个全局位索引，可以这样定位：BlockNumber = Index div BlockBits, OffsetInBlock = Index rem BlockBits, ByteOffset = OffsetInBlock div 8, BitOffset = 7 - (OffsetInBlock rem 8).设置对应位：<<Prefix:ByteOffset/binary, Byte:8, Suffix/binary>> = Block, UpdatedBlock = << Prefix/binary, (Byte bor (1 bsl BitOffset)):8, Suffix/binary >>.这种设计只需要重新构造命中的块，不需要重建整个逻辑位图。没有使用过的块也不需要提前分配。5.4 插入流程插入一个元素时：1. 计算两个基础哈希值； 2. 派生 k 个位索引； 3. 将每个索引转换为块编号和块内偏移； 4. 读取对应块，不存在时创建全 0 块； 5. 设置目标位； 6. 保存更新后的块。5.5 查询流程查询时检查全部派生位置：任意块不存在 → false 任意目标位为 0 → false 全部目标位为 1 → true可以使用 lists:all/2 表达这种语义：lists:all( fun(Index) -> bit_is_set(Index, Blocks) end, Positions ).空 Bloom Filter 可以直接返回 false，避免不必要的哈希和位图计算。5.6 新增和删除的正确性新增键时，必须同步更新 Bloom Filter：写入持久化存储 + 加入 Bloom Filter如果持久化存储已经存在某个键，而 Bloom Filter 没有记录它，查询可能被错误拦截，从而产生假阴性。普通 Bloom Filter 通常不支持安全删除。因为同一个位可能同时被多个元素使用，直接清零可能影响其他元素。常见处理方式是：删除持久化记录 保留 Bloom Filter 中原有的位删除后可能增加假阳性，但不会造成假阴性。Bloom Filter 返回 true 时继续查询持久化存储，仍能得到正确结果。5.7 失败降级Bloom Filter 是性能优化层，不应成为正确性的单点。如果过滤器构建失败、状态不可用，或者无法确认是否包含全部已有键，安全的降级方式是：跳过 Bloom Filter 直接查询权威存储不能把“过滤器不可用”解释为“键不存在”。
Neovim 使用 ELP 内存占用过高的一次排查记录
Neovim 使用 ELP 内存占用过高的一次排查记录最近在 Neovim 中使用 ELP（Erlang Language Platform） 作为 Erlang 的 LSP 服务器时，遇到了一次比较典型的内存占用过高问题。ELP 本身是 Rust 实现，语义能力比较强，理论上性能也不错。但在实际项目中，ELP 进程的常驻内存一度达到 1.3GB～1.4GB，明显偏高。本文记录这次排查过程，以及最终定位到的根因。一、问题背景项目是一个中等规模的 Erlang 服务端工程：Erlang 源码与头文件数量：约 1000+ 个 .erl / .hrl项目磁盘占用：数百 MB编辑器：NeovimLSP：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 本身，而在“把数据生成成源码”这件事对开发工具链并不友好。
[译注与拓展] 哈希数组映射树（HAMT）的原理与Erlang对其的实现
声明： 本文主要内容编译自 Nick M 的英文技术分享 《Grokking Hash Array Mapped Tries (HAMTs)》。笔者在译介的基础上，修正了原作中关于哈希冲突处理的逻辑细节，并观察了 Erlang 源码分析出 Erlang 虚拟机对 HAMT 的实际工业级优化策略。这是一种结合了哈希表和 Tries（字典树）优势的数据结构，用于高效地存储和检索键值对。在 HAMT 里面，Keys 都被 hash 了来确认在 Array 的位置，被称之为 hash array。为了确保高效的内存利用，如果多个 Key 在同一个 index 上冲突了，那么 trie-like（类字典树）的结构就是用来解决这种冲突的。用个例子来解释 HAMT 怎么运作：假设我们有一个 HAMT 来存一些单词和单词对应的定义。我们用一个简单的哈希表说明，它只有四个 Slots 来存 Value，和只用 2 个 bits 来代表 index。我们存下面这几个键值对："apple" -> "a fruit""banana" -> "a tropical fruit""cat" -> "a small animal"现在我们的 HAMT 还是空的：根表 (Root Table) +------+------+------+------+ | [00] | [01] | [10] | [11] | +------+------+------+------+ | 空 | 空 | 空 | 空 | +------+------+------+------+我们开始插入第一个键值对 "apple" -> "a fruit"，"apple" 这个 Key 被哈希之后，在哈希表中的下标位置就是确定的。我们假设 "apple" 的哈希值是 32bits，但是我们只用前两个 bits 来找到在这个 Hash 表中正确的下标位置。在这个例子里面，我们假设这两个 bits 就是 01。我们一看这个下标在上面的哈希表中还是空的，于是直接存进去这个位置。根表 (Root Table) +------+-----------------------+------+------+ | [00] | [01] | [10] | [11] | +------+-----------------------+------+------+ | 空 | "apple" -> "a fruit" | 空 | 空 | +------+-----------------------+------+------+接下来我们来插入 "banana" -> "a tropical fruit"。我们故技重施，hash 一下这个 Key，相应的 index 就能找到了。我们假设计算出来这个 Key 的哈希值是以 10 开始的，这个 10 在上面的哈希表内的位置依旧是空的，所以也是直接插入。根表 (Root Table) +------+-----------------------+-----------------------------+------+ | [00] | [01] | [10] | [11] | +------+-----------------------+-----------------------------+------+ | 空 | "apple" -> "a fruit" | "banana" -> "a trop. fruit" | 空 | +------+-----------------------+-----------------------------+------+最后我们来插入 "cat" -> "a small animal"。哈希一下这个 Key，确定其 index。我们假设 "cat" 的哈希值的头两位又变成了 01。哦，这个位置我们之前放入过 "apple"，发生了冲突！正常来说，当一个传统的哈希表变满时，我们需要分配一个更大、更多的哈希表，然后重新计算并搬迁所有旧的值（re-hash），这是一个又慢又昂贵（expensive）的过程。所以我们有没有能避免这种不得不执行 resize 操作的方法？哈哈，HAMTs 就是来搞这个的。与其去执行一个 resize 我们的表，不如我们直接分配一个新的子哈希表（也是只有 4 个空 Slots），我们用这个新哈希表链接到我们发生冲突的 Slots（01）位置。这里有一个非常关键的步骤：原本在第一层 01 槽位上的 "apple" 不能继续留在原地了（因为该槽位现在要变成一个指向新子表的指针）。我们需要把原有的 "apple" 和新来的 "cat" 一起向下推（Push Down），重新分配到第二层的新哈希表里。根表 (Root Table) +------+--------+-----------------------------+------+ | [00] | [01] | [10] | [11] | +------+--+-----+-----------------------------+------+ | 空 | | | "banana" -> "a trop. fruit" | 空 | +------+--v-----+-----------------------------+------+ | v [指向子表的指针] | v 子表 (Sub-Table) +------+------+------+------+ | [00] | [01] | [10] | [11] | +------+------+------+------+ | 空 | 空 | 空 | 空 | +------+------+------+------+现在我们要把 "apple" 和 "cat" 安置在第二个表里面，但是我们需要更多的 bits 去定位它们。这种情况下，我们引入下一组 bits（第 3 和第 4 个 bits）来确定它们在第二层表的位置：我们假设 "apple" 的前 4 位哈希值是 01 00。头两位 01 帮我们找到了第一层的槽位，下两位 00 则决定了它在第二层子表中的下标为 00。我们假设 "cat" 的前 4 位哈希值是 01 10。头两位 01 帮我们找到了第一层的槽位，下两位 10 则决定了它在第二层子表中的下标为 10。根表 (Root Table) +------+--------+-----------------------------+------+ | [00] | [01] | [10] | [11] | +------+--+-----+-----------------------------+------+ | 空 | | | "banana" -> "a trop. fruit" | 空 | +------+--v-----+-----------------------------+------+ | +-------------------------+ | v 子表 (Sub-Table) +-----------------------+------+---------------------------+------+ | [00] | [01] | [10] | [11] | +-----------------------+------+---------------------------+------+ | "apple" -> "a fruit" | 空 | "cat" -> "a small animal" | 空 | +-----------------------+------+---------------------------+------+看，通过这种“向下推”和“子表局部挂载”的方法论，我们完美避免了全局的 re-hashing。如果我们继续这样搞，我们就能增加尽可能多的 values，而且不用重新分配整张大表的内存。当我们需要查找一个值时，只需要简单地根据哈希值一路找下去。我们先看最低的两位，在根表（Root Table）寻找：如果对应位置是空，说明值不存在；如果对应位置是一个直接的值，且 Key 对上了，就直接返回；如果对应位置是一个指针（指向子表），我们就继续提取哈希值的下两位，去第二层子表里找，以此类推。工业界是如何处理的？以 Erlang 为例在实际的工业级编程语言实现中，比如 Erlang 的虚拟机（BEAM），对 HAMT 的处理更加极致和聪明，解决了很多现实中的性能和内存痛点：大 Map 与小 Map 的双重策略（Flatmap vs Hashmap）：在 Erlang 中，并不是所有的 Map 一上来就会用 HAMT。如果键值对数量很少（通常少于 32 个），Erlang 会使用一种非常简单、扁平的数组结构（称为 flatmap），因为在小数据量下，直接遍历数组比遍历树还要快。只有当元素数量超过 32 个时，它才无缝“升级”为真正的 HAMT（在源码中称为 hashmap）。用 Bitmap（位图）消灭空槽，节省内存：在我们上面的简化例子中，第二层表虽然只存了 2 个值，却分配了 4 个空槽。如果树很深，会浪费大量的内存。Erlang 的 HAMT 节点通常是 16 叉的（每次看 4 个 bits）。为了不浪费这 16 个槽位，Erlang 内部使用了一个 **16 位的无符号整数（Bitmap）**来标记哪些位置是有值的。物理上，它只为有值的槽位分配连续的内存，再通过极快的位运算（如 popcount 指令）计算出元素在物理内存中的实际偏移量。终极哈希冲突的兜底：万一遇到极罕见的“完全哈希冲突”（即两个不同的 Key，它们的 32 位或 64 位哈希值完全一样），再怎么向下建树也分不开了。Erlang 在源码中对此做了兜底：它会在树的最底部创建一个特殊的冲突节点（Collision Node），其内部退化成一个简单的链表，用来安全地容纳这些具有相同哈希值的不同 Key。总结通过这种方式，HAMTs 高效地用类 trie 的数据结构处理了哈希冲突，本质上这是一种结合了二进制搜索和哈希表、但是没有它们那么麻烦的东西。在二进制搜索树中，我们每次插入新元素时，为了使搜索路径更有逻辑和高效，不得不进行复杂的旋转去重新平衡（Rebalance）。而在 HAMT 里面，元素的位置通过哈希值决定，分布更加随机，根本不需要专门去使其平衡。至于哈希表，我们已经提及过当表太大了的时候需要重新分配更多内存和 re-hash 的问题了。HAMT 不会变得很 "full"，因为本质上这是一棵树，我们需要分配新的值只需要往上面挂子节点就行了。HAMT 被频繁用于函数式编程语言，比如 Clojure、Scala 以及 Erlang，用来实现持久化数据结构比如 maps 和 sets。HAMT 在确保不可变性（Immutability）时提供了高效的查询、插入和删除操作。它们也很适合多线程或者多进程去同时访问和修改共享数据结构这种并发情况。这种结构性的共享特征允许高效地复制共享数据，减少了昂贵的锁机制需要。相关链接：Grokking Hash Array Mapped Tries (HAMTs) - by Nick Mhttps://github.com/erlang/otp/blob/master/erts/emulator/beam/erl_map.c
一次 Erlang 线上内存优化分析：ETS 大对象、compressed 表与 eheap allocator 碎片背景近期对一批 Erlang 线上节点做内存采样时，发现整体内存水位偏高。采样内容主要包含两部分：ETS 表维度的内存占用；ets_alloc 与 eheap_alloc 分配器的 block、carrier、usage 情况。从采样结果看，这批节点的内存问题并不是单一来源，而是由两类问题叠加造成：部分核心业务 ETS 表中存在大对象，尤其是超长 list、复杂 record、大 map 或嵌套结构，导致单条记录非常重；eheap_alloc 的 mbcs carrier 利用率偏低，部分高负载节点在进程堆分配高峰后留下较大的 carrier 空洞。这两类问题性质不同：ETS 数据结构优化，解决的是业务数据本体过大的问题；allocator 参数调整，解决的是进程堆分配碎片和 carrier 利用率偏低的问题。两者可以叠加优化，但需要分别评估收益、性能成本和适用场景。一、线上内存现状1. 节点整体情况本次采样覆盖节点数：节点数：444从 ETS 表占用看，内存主要集中在少数几个大表上。尤其是某个核心业务表，单表全服可见占用已经达到：核心业务表 X 当前可见 ETS 占用：约 227.70 GiB从 allocator 看，eheap_alloc 的 mbcs 当前全服情况如下：eheap_alloc mbcs: block = 74.87 GiB carrier = 157.78 GiB waste = 82.91 GiB也就是说，eheap_alloc 的 mbcs 中有相当一部分 carrier 已经被申请出来，但当前并没有被有效使用。2. ETS 表占用特征ETS 侧的问题比较明确：内存主要集中在少数大表中，其中最突出的表是核心业务表 X。这类表有几个典型特征：单条记录很大；记录数量不一定特别夸张，但 value 结构非常重；value 中包含超长 list、复杂 record、大 map、嵌套结构或冗余缓存字段；数据长期常驻 ETS/DETS，生命周期较长。在抽样分析中，某个核心记录的大致结构占用如下：单条核心记录：约 52,576 bytes 其中超长列表字段：约 51,792 bytes也就是说，单条记录的大部分内存都来自某个超长 list 字段，而不是 record 的其他字段。这个现象说明，ETS 表本身不是简单的“记录数太多”问题，而是存在明显的单条对象过胖问题。3. allocator 占用特征allocator 侧主要观察了 ets_alloc 与 eheap_alloc。ets_alloc 的整体使用率相对较高，说明 ETS 这部分主要是业务数据真实占用，并不是 allocator 碎片导致的假性膨胀。相比之下，eheap_alloc 的 mbcs 问题更明显：eheap_alloc mbcs block = 74.87 GiB eheap_alloc mbcs carrier = 157.78 GiB eheap_alloc mbcs waste = 82.91 GiB这说明进程堆分配曾经被拉高，carrier 扩张后没有充分回收，导致保留了大量低利用率 carrier。部分同类负载节点的 eheap_alloc mbcs usage 只有：10% - 30%单节点碎片空间可能达到：500 MiB - 1 GiB因此，这批节点的内存问题可以拆成两层：ETS 大表是真实业务数据占用，是主因；eheap_alloc 存在明显 carrier 浪费，是额外放大的因素。二、核心问题定位1. 核心业务表 X 中存在超长 list 字段抽样结果显示，某个核心业务表 X 中有一个超长列表字段，占用了单条记录的大部分空间。抽样数据如下：单条记录：约 52,576 bytes 超长列表字段本身：约 51,792 bytes将该列表字段压缩后：term_to_binary(List, [compressed]) 后正文：约 1,612 bytes 压缩后整条 record：约 2.4 KB 单条缩小比例：约 95%这说明该字段非常适合压缩存储。对于这类“写入后长期存放、读取频率相对可控、字段内容重复度较高”的列表数据，直接以 Erlang list 常驻 ETS 的成本非常高。2. 大 term 表适合 ETS compressed除了针对某个字段做 term_to_binary(..., [compressed]) 之外，还测试了 ETS 表自身的 compressed 参数。例如某类大对象业务表中存放的是完整的大 record。开启 ETS compressed 后，内存下降非常明显。测试结果中，某张大对象业务表的内存从：25167931 words下降到：6677897 words压缩后约为原来的：26.5%也就是：内存减少约 73.5% 内存约减少 3.77 倍这说明，对于 value 是大 record、大 tuple、大 map 或嵌套 term 的 ETS 表，compressed 是一个非常有效的内存优化手段。3. 小对象高密度表不一定适合 compressed并不是所有 ETS 表都适合开启 compressed。在测试中，有一张高密度业务表开启 compressed 后反而变大：优化前：约 499.6 MiB 优化后：约 518.1 MiB 增加：约 18.5 MiB这类表的特点是：记录数量非常多；单条 value 很小；平均单条只有几百字节；压缩元数据、对象布局和访问结构开销可能抵消压缩收益。因此，ETS compressed 更适合“大 term 表”，不适合盲目推广到所有 ETS 表。4. eheap_alloc 的 mbcs carrier 利用率偏低另一个问题来自 eheap_alloc。在未调整 allocator 参数的节点上，很多同类负载节点存在较大的 mbcs carrier 浪费：usage = 10% - 30% 单节点 waste = 500 MiB - 1 GiB而某个已调整 allocator 参数的样本节点表现如下：mbcs block = 329.8 MiB mbcs carrier = 458.6 MiB waste = 128.8 MiB usage = 71.9%对比来看，调整后 eheap_alloc mbcs 的 carrier 利用率明显更好。这说明对于该类负载，适当调整 eheap_alloc 的 mbcs 策略，有机会减少进程堆 allocator 的碎片和 carrier 保留。三、局部压缩实测为了验证 ETS 压缩收益，选取了一个测试节点，对部分大表进行了压缩对比。1. 优化前 Top ETS 表优化前，Top ETS 表大致如下：业务表 X 1,024,774,056 bytes 19,626 rows 业务表 A 523,826,456 bytes 2,422,681 rows 业务表 B 296,529,864 bytes 974,885 rows 业务表 H 217,739,000 bytes 6,876 rows 业务表 J 201,343,448 bytes 635 rows 业务表 C 197,554,088 bytes 31,176 rows 业务表 D 172,530,048 bytes 31,918 rows 业务表 E 171,194,880 bytes 750,870 rows 业务表 F 98,440,520 bytes 33,684 rows 业务表 G 90,481,488 bytes 24,000 rows可以看到，业务表 X 单表接近 1 GiB，是该节点上最突出的 ETS 内存来源。2. 优化后 Top ETS 表优化后，Top ETS 表发生了明显变化：业务表 A 543,211,160 bytes 2,422,696 rows 业务表 B 287,866,928 bytes 974,885 rows 业务表 C 197,554,072 bytes 31,176 rows 业务表 D 172,530,624 bytes 31,918 rows 业务表 E 171,194,880 bytes 750,870 rows 业务表 F 98,450,456 bytes 33,684 rows 业务表 G 90,481,488 bytes 24,000 rows 业务表 H 60,932,032 bytes 6,755 rows 业务表 I 54,894,752 bytes 12,914 rows 业务表 J 53,423,176 bytes 635 rows其中，原先占用最高的业务表 X 已经不再出现在 Top 列表中，说明压缩后该表的内存占用已经从主要矛盾变成了非主要项。进一步查看该表的 ets:info/1：[{compressed,true}, {memory,1355986}, {size,19626}, {type,set}, {keypos,2}, {protection,public}]需要注意：ets:info(Tab, memory) 的单位是 word，不是 byte。在 64 位 VM 下，1 word = 8 bytes，因此：1355986 * 8 = 10847888 bytes也就是：优化后业务表 X 约占 10.35 MiB3. 业务表 X 压缩收益对比优化前后：优化前：1,024,774,056 bytes，约 977.3 MiB 优化后：10,847,888 bytes，约 10.35 MiB 节省：1,013,926,168 bytes，约 967.0 MiB 降幅：约 98.9%这个结果和之前对超长 list 字段的抽样判断是一致的。也就是说，该表的大头确实来自超长列表字段，压缩后内存占用出现数量级下降。4. 几张典型表的压缩对比这次测试中，几张典型表的变化如下：表类型优化前优化后变化业务表 X977.3 MiB10.35 MiB节省 967.0 MiB业务表 H207.7 MiB58.1 MiB节省 149.5 MiB业务表 J192.0 MiB51.0 MiB节省 141.1 MiB业务表 B282.8 MiB274.5 MiB节省 8.3 MiB业务表 A499.6 MiB518.1 MiB增加 18.5 MiB这 5 张目标表合计：优化前合计：约 2158.4 MiB 优化后合计：约 912.0 MiB 净节省：约 1246.4 MiB 总降幅：约 57.7%从结果看：业务表 X 压缩非常成功，单表节省接近 1 GiB；业务表 H 和业务表 J 也非常适合 ETS compressed；业务表 B 收益很小；业务表 A 反而变大，不适合保留 compressed。四、ETS compressed 的性能代价ETS compressed 的收益并不是免费的。在业务表 J 的读写测试中，开启 compressed 后，内存收益明显，但读写性能会下降。以未压缩表作为基准，压缩表表现大致如下：命中读 lookup hit：慢约 24% - 29% 随机命中读 rand lookup：慢约 22% - 26% 未命中读 lookup miss：快约 27% - 31% 更新写 insert update：慢约 75% - 85% 写入覆盖类测试：慢约 74% - 83%可以总结为：内存节省非常明显，约 73.5% 命中读取性能下降约 20% - 30% 写入性能下降约 75% - 85% miss 读取略快，但通常业务价值不如 hit/read 和 write 重要所以对大 record、大 tuple、大 map 这类对象，ETS compressed 的典型特征是：很省内存；读稍慢；写明显更慢。因此，是否开启 compressed，不能只看内存收益，还要结合表的访问模式。更适合开启 compressed 的表：value 很大；读多写少；单条 term 重复度高；不在极致低延迟路径上；内存压力明显大于 CPU 压力。不适合开启 compressed 的表：value 很小；写入非常频繁；每次请求都高频命中读取；单条数据压缩收益有限；开启后内存不降反升。五、优化项与收益预估1. 压缩核心业务表 X 中的超长列表字段建议做法将核心业务表 X 中的超长 list 字段从直接存储 list，改为压缩后的 binary。示意结构：{compressed, Bin}写入时：Bin = term_to_binary(List, [compressed])读取时：List = binary_to_term(Bin)也就是说，整条记录可以保持压缩形态在业务流程中传递，只有访问该字段时才解压。这类字段级压缩比整表 compressed 更可控，适合特别明确的大字段。收益预估当前该核心业务表 X 全服可见占用：227.70 GiB按保守 80% 回收估算：227.70 GiB * 80% = 182.16 GiB如果按更谨慎的 50% 回收估算：227.70 GiB * 50% = 113.85 GiB因此，该项优化的预估收益区间为：约 114 GiB - 182 GiB其中，按 80% 回收估算是相对合理的保守目标；按 50% 回收估算则是非常谨慎的底线口径。从局部实测看，业务表 X 单节点压缩后降幅达到约 98.9%。全服估算继续使用 50% - 80% 的口径，属于偏保守估算。2. 对大 term ETS 表启用 compressed除了字段级压缩外，还可以对部分大 term 表启用 ETS compressed。适合的对象包括：存放大 record 的业务表；存放大 tuple 的业务表；存放大 map 的业务表；value 明显大于 key 和表结构开销的 ETS 表。局部实测中，业务表 J 内存约下降 73.5%；业务表 H 也从约 207.7 MiB 降到 58.1 MiB，节省约 149.5 MiB。但是，表级 compressed 需要逐表验证，不应全局盲开。尤其是小对象高密度表，可能出现不降反升：业务表 A： 优化前约 499.6 MiB 优化后约 518.1 MiB 增加约 18.5 MiB因此，ETS compressed 的使用原则应该是：大对象表优先测试 小对象高密度表谨慎使用 写频繁表谨慎使用 压缩后必须用真实数据回归内存和性能3. 调整 eheap_alloc 的 mbcs 策略建议做法在全服逐步启用：+MHlmbcs 1024该参数用于调整 eheap_alloc 中 mbcs carrier 的行为，目标是改善进程堆 allocator 的 carrier 利用率，减少高峰后遗留的大量低利用率 carrier。从已启用样本看，效果比较明显：mbcs block = 329.8 MiB mbcs carrier = 458.6 MiB waste = 128.8 MiB usage = 71.9%而未调整的同量级节点中，部分节点仍然存在：usage = 10% - 30% 单节点 waste = 500 MiB - 1 GiB收益预估基于已启用样本节点，对全服 eheap_alloc mbcs 做推断：很保守：约回收 46.22 GiB 保守：约回收 50.51 GiB 中性偏好：约回收 55.29 GiB对外建议采用更稳妥的口径：保守口径：46 GiB 中性口径：50 GiB这项优化不是业务数据本体压缩，而是减少 allocator carrier 浪费。因此它的收益不一定在所有节点上完全一致，但对高负载、高碎片节点更可能有效。六、两项主优化叠加后的收益这里先只按两项主优化估算：核心业务表 X 超长字段压缩；eheap_alloc 参数调整。按保守主口径合并：核心业务表 X 压缩：约 182 GiB eheap_alloc 参数调整：约 46 GiB 合计：约 228 GiB如果对核心业务表 X 压缩采用更谨慎的 50% 回收口径：核心业务表 X 压缩：约 114 GiB eheap_alloc 参数调整：约 46 GiB 合计：约 160 GiB因此，本轮两项主优化合计预计可回收：约 160 GiB - 228 GiB比较稳妥的容量收益目标，可以先按：约 200 GiB进行评估。需要注意，其他大 term 表开启 ETS compressed 的收益没有纳入这个主口径。原因是表级 compressed 需要逐表验证，不能直接按全服统一比例外推。七、容量价值估算这里计算的是“容量价值”，不是立即等同于账单下降。只有当优化结果能够带来以下变化时，才会转化为真实成本节省：降低机器规格；减少扩容需求；延后新增机器；提高单机承载密度；减少内存水位告警和 OOM 风险。按总节省：160 GiB - 228 GiB估算。按 15.75 元/GiB/月计算月度价值：约 2,520 - 3,591 元/月 年度价值：约 3.0 - 4.3 万元/年按 27.75 元/GiB/月计算月度价值：约 4,440 - 6,327 元/月 年度价值：约 5.3 - 7.6 万元/年按 200 GiB 保守主口径计算15.75 元/GiB/月： 约 3,150 元/月 约 3.78 万元/年 27.75 元/GiB/月： 约 5,550 元/月 约 6.66 万元/年八、结论这批 Erlang 节点的内存问题，主因已经比较明确：核心业务表 X 中存在超长 list 字段，导致单条记录过大，业务数据本体占用偏高；一些存放大 record、大 tuple、大 map 的 ETS 表，也存在明显压缩空间；eheap_alloc mbcs 存在明显 carrier 浪费，部分节点在进程堆分配高峰后利用率较低。从实测结果看：业务表 X： 优化前约 977.3 MiB 优化后约 10.35 MiB 节省约 967.0 MiB 降幅约 98.9%业务表 J 开启 ETS compressed 后：内存约降到原来的 26.5% 内存减少约 73.5%但 ETS compressed 并不适合所有表。对于小对象高密度表，压缩后可能不降反升。因此，本次优化可以拆成三类：对明确的大字段做字段级压缩；对大 term ETS 表逐表测试并启用 compressed；对高碎片节点调整 eheap_alloc mbcs 策略。预估收益方面，先只按两项主优化计算：核心业务表 X 压缩：约 114 GiB - 182 GiB eheap_alloc 参数调整：约 46 GiB - 50 GiB 合计：约 160 GiB - 228 GiB可以先按：约 200 GiB作为本轮优化的容量收益目标。折算容量价值：约 3,150 - 5,550 元/月 约 3.8 - 6.7 万元/年其中，核心业务表 X 字段压缩是主要收益项；ETS compressed 适合作为大 term 表的补充优化；eheap_alloc 参数调整则是低侵入、低风险的 allocator 层补充收益项。整体来看，这次优化值得做，且优先级较高。但具体落地时，不能简单地“所有 ETS 表都开启 compressed”，而应该按表的 value 大小、读写比例、压缩收益和性能代价逐表判断。
Needle：26M 参数超轻量级工具调用（Function Calling）模型Needle 是一项模型蒸馏的标志性成果——它将 Google Gemini 的工具调用能力压缩到仅 26M 参数的微型模型中。这意味着开发者可以在资源极度受限的边缘设备上部署高质量的函数调用能力，而无需依赖云端大模型。即使在 E5 + 1030 或单核 800M 内存的 VPS 上也能流畅运行。项目地址：https://github.com/cactus-compute/needle安装与快速启动1. 克隆仓库并安装git clone https://github.com/cactus-compute/needle.git cd needle source ./setup # 自动创建虚拟环境并安装依赖2. 启动 Playground（推荐）needle playground打开浏览器访问 http://127.0.0.1:7860首次运行会下载 needle.pkl（约 50MB）的模型文件到项目根目录的 checkpoints/Playground 提供 Web UI，可直接测试工具调用，也支持一键基于你的工具数据微调模型CLI 使用示例进入虚拟环境后，可直接使用 needle 命令：# 激活虚拟环境（如果未自动激活） source .venv/bin/activate单次推理（Run）needle run \ --checkpoint checkpoints/needle.pkl \ --query "I want to get a full system overview" \ --tools "[ {\"name\":\"mem\",\"description\":\"View Erlang VM memory usage. Returns byte counts for various memory types (total, processes, ETS, binary, etc.).\",\"parameters\":{}}, {\"name\":\"info\",\"description\":\"View comprehensive Erlang VM runtime information, including node name, scheduler count, process count/limit, ETS count/limit, and detailed memory usage.\",\"parameters\":{}}, {\"name\":\"top_mem\",\"description\":\"View the top 10 processes with highest memory usage. Returns PID, registered name, initial call, current function, memory, reductions, and message queue length for each.\",\"parameters\":{}}, {\"name\":\"top_queue\",\"description\":\"View the top 10 processes with longest message queues. Returns PID, registered name, initial call, current function, memory, reductions, and message queue length.\",\"parameters\":{}}, {\"name\":\"top_reds\",\"description\":\"View the top 10 processes with highest reductions (execution units). Returns PID, registered name, initial call, current function, memory, reductions, and message queue length.\",\"parameters\":{}}, {\"name\":\"top_bin\",\"description\":\"View the top 10 processes with highest binary memory usage. Returns PID, registered name, initial call, current function, memory, reductions, and message queue length.\",\"parameters\":{}}, {\"name\":\"ets\",\"description\":\"View ETS table information sorted by memory usage (top 20 tables). Returns table name, memory (in words), and size (number of entries).\",\"parameters\":{}}, {\"name\":\"mem_detail\",\"description\":\"View detailed Erlang VM memory allocation including allocator states: VM allocated memory, per-type usage, and allocator fragmentation details.\",\"parameters\":{}} ]"重要提示：--query 和 --tools 目前必须使用英文，中文输入可能会导致输出乱码。--tools 参数需传入合法的 JSON 数组字符串（每个工具包含 name、description、parameters）。示例输出（模型会自动决定调用哪个工具）：<tool_call>[{"name":"info","arguments":{}}]实际使用注意事项模型体积小：26M 参数，推理速度极快，适合边缘设备、嵌入式系统、低配服务器。专精工具调用：在单次函数调用任务上表现优秀，适合构建本地 Agent。支持微调：通过 Playground 或 needle finetune 命令，使用自己的工具数据集即可快速微调，生成专属模型。中文支持：当前版本对中文 query 会直接乱码，推荐使用英文提示词以获得最佳效果。适用场景低资源设备上的本地 Agent隐私敏感场景（完全离线运行）嵌入式系统、智能硬件的工具调用快速原型验证工具调用逻辑
游戏开发中模糊需求的应对：用「最小原型 + 反向校准」替代传统前置对齐背景：我们一直面临的沟通困境在游戏功能开发中，策划经常会给出一份相对模糊的需求文档。程序员阅读后往往无法完全把握核心意图，尤其是一些涉及服务端状态流转、边界处理、异常路径、权限控制、跨系统协作的内容，很难通过纯语言描述清楚。传统流程通常是：策划输出文档程序员（或 AI）尝试理解并实现双方花费大量时间进行语言层面的对齐即使对齐了，实际做出来也经常偏离预期这种「信息传递损耗」几乎是不可避免的。无论程序员如何仔细阅读、如何用 chain-of-thought 约束 AI，最终还是容易因为隐式假设、状态边界理解偏差而返工。我的思考：反向校准是否比纯前向对齐更有效？我提出了一种反向处理的思路：不强求在编码前把需求完全说清楚，而是先基于原始文档快速落地一个最小可运行的原型，再用这个原型作为媒介，反向和策划进行对齐。核心逻辑是：代码是可执行的、具体的，比抽象文档更能暴露理解偏差。策划看到实际运行效果后，反馈会更精准。程序员也不用在前期和策划进行大量低效的语言拉锯。这种方式把「沟通媒介」从纯语言升级为「可运行原型 + 结构化分析报告」，在需求天然模糊的游戏开发领域，性价比更高。工具选择：Superpowers + OpenSpec 的组合要让这个思路真正可落地，我选择了目前开源生态中比较成熟的两个工具组合：Superpowers：一个 agentic skills 框架，能给 AI 编码代理提供结构化的开发流程（计划、TDD、代码审查、子代理执行等），属于 execution 层。OpenSpec：一个轻量级的 Spec-Driven Development 框架，负责把需求结构化成可追溯的变更文件夹（proposal、specs、design、tasks），属于 spec 管理层。两者可以很好地协同使用。推荐的实际工作流（针对策划需求模糊场景）经过讨论，我们最终形成了一个更务实的混合流程：阶段 1：基于原始文档生成最小可运行原型策划给出初始（可能模糊）的需求文档。程序员不强行先用 OpenSpec 做完整前置 spec。直接使用 Superpowers + Code Agent（Claude Code、Codex 等），基于原始文档生成一个最小可运行的框架（能把核心状态流转、关键接口、主要业务流程跑起来即可）。这一步的目标是「可验证」，而不是「完整」。阶段 2：Agent 进行反向校准让 Agent 以「原始文档 + 已生成的原型代码」为输入，进行反向分析。输出结构化报告，包含：原型实际实现了什么（便于策划理解）与原始文档的偏差点文档未说明但代码必须决定的隐式假设边界处理、异常路径、状态一致性等存在风险的位置需要向策划澄清的具体问题清单阶段 3：用原型 + 报告与策划进行对齐把最小原型实际跑给策划看。结合反向校准报告，针对具体问题点进行讨论。这一步的核心价值是：把「文档没写清楚、状态流转不明确、边界缺失、异常情况未定义」这类问题，明确地转移给策划承担，而不是让程序员一直猜测和背锅。阶段 4：对齐后用 OpenSpec 固化需求对齐完成后，使用 OpenSpec 生成正式的 proposal.md、specs/、tasks.md 等结构化文档。后续精炼实现时，就基于这个确认过的 spec 进行。这种方式的核心价值1. 更好的职责划分程序员的主要职责是「把当前理解执行成可验证的原型 + 用工具结构化地暴露偏差」。策划的职责是「通过原型确认和补充需求」。双方都不需要在信息严重不对称的情况下做出过重承诺。2. 降低沟通成本用「可运行原型」作为媒介，比纯语言对齐效率更高、误解更少。3. 减少程序员无效精力消耗程序员不需要在前期花费大量时间去「猜」策划到底要什么，而是通过执行来暴露问题。4. 更适合游戏服务端开发的现实很多服务端需求本身就涉及复杂状态、异步流程、跨系统协作与边界控制，仅靠文档很难一次性描述完整。这种方式更尊重这个现实。适用场景与注意事项适合场景：需求具有较高不确定性（系统功能、服务端逻辑、状态流转、活动框架等）策划难以在编码前给出清晰确认团队希望减少前期无效对齐时间需要注意的点：最小原型的质量仍然重要，不能完全放飞。即使使用 Superpowers，也需要程序员对 Agent 的输出进行判断和把关。如果团队流程强行要求「所有需求必须先评审确认」，则需要先解决流程层面的阻力。反向校准不是用来逃避沟通，而是用更好的媒介进行更高效的沟通。总结在 AI 编码能力已经比较强的今天，「先快速做出最小可验证原型 → 用结构化方式反向校准 → 再固化需求」这种模式，比传统「强求前期完全确认」在很多游戏服务端开发场景下更加务实。它不是在回避沟通，而是在用执行 + 工具把原本模糊的沟通，变成了更具体、可讨论、可追溯的过程。这或许是当前阶段，结合 Superpowers 和 OpenSpec 能做到的、比较高明的职责划分方式。参考工具：Superpowers: https://github.com/obra/superpowersOpenSpec: https://github.com/Fission-AI/OpenSpec
Erlang gb_trees 与 General Balanced Trees查看 Erlang/OTP 28.3.1 中的 gb_trees 源码General balanced trees.This module provides Prof. Arne Andersson's General Balanced Trees. These have no storage overhead compared to unbalanced binary trees, and their performance is better than AVL trees.官方文档是这样描述的。不过，Arne Andersson's General Balanced Trees 并不是指 AA Tree，而是源自 Arne Andersson 在 1999 年发表的一篇论文。论文讨论了一种性能优于 AVL 树的通用平衡二叉树，其特点如下。主要特点零存储开销（Zero Storage Overhead）每个节点不需要存储平衡因子，只需存储一个总大小和一个删除次数。更低的摊还重构成本（Lower Amortized Restructuring Cost）对左右子树的高度不敏感，只有树高超过 c log n 的界限时才进行干预。论文指出，虽然偶尔需要以稍高的代价重构子树，但维护整棵树的总工作量比 AVL 树更低。算法逻辑简单（Simplicity）General Balanced Trees 只关注一个全局标准：树的高度是否超过 log n。在插入和删除时，只需维护总大小和删除次数，无需进行复杂的平衡因子计算，因此实现更加高效、简洁。为什么说它比 AVL 树性能更好？特性AVL 树General Balanced Trees（论文方案）优势存储需要存储平衡因子（per-node overhead）无节点存储开销，仅需两个全局整数更节省内存，对缓存更友好维护策略每次修改都可能触发旋转（strict）懒惰重建（lazy/amortized）频繁修改时，总旋转或重构工作量更少时间复杂度单次操作最坏为 O(log n)单次操作摊还为 O(log n)AVL 保证单次操作的最坏时间；GB 树降低多次操作的总体维护成本