Liz'Blog - 默认分类
type: channel
link: https://www.lizzycat.cn/blog/category/default/
description: 只是一个默认分类
items: 理解 Erlang/OTP 的 Thread Progress
items: Erlang 载体迁移机制解析：从无锁池设计到 NUMA 架构的高效适配
items: 双重哈希：从开放寻址到 Bloom Filter 工程实践
items: Neovim 使用 ELP 内存占用过高的一次排查记录
items: [译注与拓展] 哈希数组映射树（HAMT）的原理与Erlang对其的实现
items: 线上内存优化方案实战
items: Needle小模型
items: 基于原型驱动的ai工作流程
items: gb_trees

理解 Erlang/OTP 的 Thread Progress
type: item
date: 2026-08-04T07:41:00+00:00
description: 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 从…

Erlang 载体迁移机制解析：从无锁池设计到 NUMA 架构的高效适配
type: item
date: 2026-07-22T07:11:23+00:00
description: 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 = …

双重哈希：从开放寻址到 Bloom Filter 工程实践
type: item
date: 2026-06-23T09:38:00+00:00
description: 双重哈希：从开放寻址到 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…

Neovim 使用 ELP 内存占用过高的一次排查记录
type: item
date: 2026-06-12T13:11:00+00:00
description: 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 = f…

[译注与拓展] 哈希数组映射树（HAMT）的原理与Erlang对其的实现
type: item
date: 2026-06-05T09:16:00+00:00
description: 声明： 本文主要内容编译自 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），重新分配到第二层的…

线上内存优化方案实战
type: item
date: 2026-05-27T08:19:00+00:00
description: 一次 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 后，内存下降非常明显。测试结果中，某张…

Needle小模型
type: item
date: 2026-05-25T12:54:00+00:00
description: 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.\",\…

基于原型驱动的ai工作流程
type: item
date: 2026-05-25T09:15:00+00:00
description: 游戏开发中模糊需求的应对：用「最小原型 + 反向校准」替代传统前置对齐背景：我们一直面临的沟通困境在游戏功能开发中，策划经常会给出一份相对模糊的需求文档。程序员阅读后往往无法完全把握核心意图，尤其是一些涉及服务端状态流转、边界处理、异常路径、权限控制、跨系统协作的内容，很难通过纯语言描述清楚。传统流程通常是：策划输出文档程序员（或 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 编码能力已经比较强的今天，「先快速做出最小可验证原型 → 用结构化方式反向校准 → 再固化需求」这种模式，比传统「强求前期完全确认」在很多游戏服务端开发场景下更加务实。它不是在回避沟通，而是在用执行 + 工具把原本模糊的沟通，…

gb_trees
type: item
date: 2026-05-02T01:30:00+00:00
description: 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 树降低多次操作的总体维护成本