优化Markdown解析器的内存使用

Lobsters Hottest 新闻

摘要

这篇博客文章详细介绍了优化C++ Markdown解析器内存使用的技术,通过arena分配和指针压缩,将AST节点大小从232字节显著减少到16字节,同时保持解析速度。

<p><a href="https://lobste.rs/s/1swh9l/optimizing_memory_usage_markdown_parser">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/23 13:19

# 优化 Markdown 解析器中的内存使用 来源:https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html 我正在将 gpui-component(https://github.com/longbridge/gpui-component)(一个基于 GPUI 构建的 Rust UI 组件库)移植到 C++,并命名为 gpui-cpp(https://github.com/kjk/gpui-cpp)。具体来说,我的朋友 Claude 负责移植工作,我只负责指导。它使用 markdown-rs(https://github.com/wooorm/markdown-rs)(一个 CommonMark + GFM 解析器)作为 Markdown 解析器,因此我也将其一并移植。之后我对其进行了优化。本文旨在描述我所做的工作,以帮助他人学习如何优化 C++ 代码。 ## 起点 https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#the-starting-point Markdown 解析器主要有两种类型: - 解析时流式输出节点的 - 在内存中构建抽象语法树(AST)的 markdown-rs 采用构建 AST 的方式。关键在于最小化 AST 节点的大小。在 Rust 中,节点有多种类型,最大的达 152 字节。Claude 生成了一个 232 字节的单一 `Node` 结构体。我将其优化到了 16 字节。 以下是优化前的初始 `Node` 结构体: Node,232 字节 kchildren 24 position 24 8 个字符串字段 — 128 字节 align 24 nums 16 灰色 = 填充与小字段 · 蓝色 = 可增长向量 · 黄色 = 指针+长度字符串 Node,16 字节(同比例) lastKid · sibling · firstStr · kind+flags 232 字节的构成:8 个字符串字段各 16 字节(一个 `char*` 加一个长度),两个可增长向量各 24 字节(children 和 table alignments),一个 24 字节的 Unicode `Position`(每端的行、列和偏移量),六个 `bool`(各占一个字节),以及所有这些引入的填充。 树中每个节点都承担着每个字段的开销,无论其类型如何。`Text` 节点仅使用一个字符串字段,别无他用。 ## 竞技场分配器 https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#arena-allocator 所有分配都在竞技场(Arena)中进行非常重要。解析树中的节点具有相同的生命周期,这使得它非常适合使用竞技场:一个只能增长的碰撞分配器(bump allocator)。释放内存的唯一方法是重置竞技场。这与为每个节点单独调用 `malloc()` 然后再调用 `free()` 不同。它使得测量内存使用变得容易:解析后检查竞技场大小即可。这也允许进行指针压缩等优化技巧。 ## 我的测量方式 https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#how-i-measured 使用 `bun cmd/bench.ts markdown` 以四种形式解析 64 KB 的 Markdown,并报告解析分配的竞技场字节数: - **散文** — 段落、强调、链接 - **嵌套列表** — 深层引用块和列表 - **GFM 表格** — 完整结构的表格 - **实体** — 主要包含 `&` 格式字符引用的文本 该数字是整个竞技场的大小:节点、分词器的事件列表以及字符串。不仅仅是 `sizeof(Node) × 节点数`。我们还测量解析时间,以确保没有用速度换空间。 基准线,64 KB 源代码: - 散文 **1646.1 KB**(源的 25.7 倍) - 嵌套列表 **1067.9 KB** - GFM 表格 **2926.0 KB** - 实体 **660.2 KB** 在 64 位平台上,指针为 8 字节。指针压缩通过计算相对于基指针的 32 位偏移量,将其减少到 4 字节。谷歌在 V8 中使用了压缩指针并取得了显著效果(https://v8.dev/blog/pointer-compression)。减少了内存使用并提高了速度。 我们的字符串类型是最简单的字符串: ```cpp struct Str { char* data; size_t len; }; ``` 如果 `len` 是 4 字节,每个字符串至少需要 12 字节。由于对齐,大小为 16 字节。字符串分配在竞技场中,因此我们可以使用竞技场开头作为基指针,并将指针从 8 字节优化到 4 字节。我们将 `ArenaStr` 类型定义为 `uint64_t`。低 4 字节是 `uint32_t` 压缩指针,高 4 字节是大小。我们将字符串的开销从 16 字节减少到 8 字节。乘以 8 个字符串,每个节点节省 64 字节。 添加了在竞技场中分配 `ArenaStr` 以及将 `ArenaStr` 转换为 `Str` 的辅助函数。 节省:8 个字符串 * 8 字节,每节点 64 字节:**232 → 168 字节。** | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 1646.1 KB | **1285.9 KB** | -21.9% | -21.9% | | 嵌套列表 | 1067.9 KB | 1067.9 KB | **867.5 KB** | -18.8% | -18.8% | | GFM 表格 | 2926.0 KB | 2926.0 KB | **2269.7 KB** | -22.4% | -22.4% | | 实体 | 660.2 KB | 660.2 KB | **626.2 KB** | -5.1% | -5.1% | 一些字符串需要增长。竞技场分配器不提供释放或重新分配。只能分配新字符串,这会留下我们追加过的字符串的无效副本,从而浪费内存。我们可以增长最后分配的字符串,这就是此更改所做的。幸运的是,大多数追加操作都是在最后一个字符串上进行的。 `ArenaStrAppend` 检查字符串是否正好结束在竞技场下一次分配开始的地方。如果是,则新字节直接追加到其上,无需复制。解码 HTML 实体(例如 `&`)通过在追加到字符串之前进行分配,破坏了此优化。我们改为将实体解码到一个 4 字节的栈缓冲区,从而启用了优化追加。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 1285.9 KB | **1285.9 KB** | +0.0% | -21.9% | | 嵌套列表 | 1067.9 KB | 867.5 KB | **729.2 KB** | -15.9% | -31.7% | | GFM 表格 | 2926.0 KB | 2269.7 KB | **2269.7 KB** | +0.0% | -22.4% | | 实体 | 660.2 KB | 626.2 KB | **163.8 KB** | -73.8% | -75.2% | 除非告知打包结构体布局,C++ 编译器会将结构体字段对齐到最大基本类型的大小。如果你在两个 `uint64_t` 值之间夹一个 `bool`,`bool` 将占用 8 字节(`sizeof(uint64_t)`)而不是它应该的 1 字节。我们的 `Node` 因填充而存在这样的浪费空间。我的朋友 Claude 不够细心。一个简单的修复方法是重新排列字段,将最大的放在前面。我们还将六个 `bool` 字段打包到一个 `uint8_t flags` 字段中。 结果:**168 → 144 字节,完全没有填充。** 我们现在超越了 Rust 版本。 声明顺序:bool 在 vector 之后 = 7 字节填充,重复六次 vector | b | padding | strings | b | padding 最大字段在前,bool 合并为一个字节:无填充 vectors | strings | nums | f | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 1285.9 KB | **1150.9 KB** | -10.5% | -30.1% | | 嵌套列表 | 1067.9 KB | 729.2 KB | **654.0 KB** | -10.3% | -38.8% | | GFM 表格 | 2926.0 KB | 2269.7 KB | **2023.6 KB** | -10.8% | -30.8% | | 实体 | 660.2 KB | 163.8 KB | **151.0 KB** | -7.8% | -77.1% | 空闲字节:相同的字段、相同的代码、不同的顺序。我们对竞技场中分配的所有对象都进行指针压缩,就像我们压缩字符串指针一样。`ArenaVec children` 持有 8 字节地址;`ArenaPtr` 是一个 4 字节偏移量,指向竞技场的地址空间,通过 `ArenaAtOffset` 解析。零值表示空指针,因为没有分配落在偏移量零处,所以不产生任何开销。 `Node` 本身大小不变——向量句柄无论持有内容是什么,都是三个字宽——因此所有节省都来自子节点数组。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 1150.9 KB | **1091.9 KB** | -5.1% | -33.7% | | 嵌套列表 | 1067.9 KB | 654.0 KB | **611.6 KB** | -6.5% | -42.7% | | GFM 表格 | 2926.0 KB | 2023.6 KB | **1866.9 KB** | -7.7% | -36.2% | | 实体 | 660.2 KB | 151.0 KB | **144.9 KB** | -4.0% | -78.1% | 这些形状的排名依据是每个节点的子节点数,而非节点数,这就是为什么表格改善最多。 `ArenaStr` 曾是一个 8 字节的偏移量和长度。现在它只是偏移量本身——4 字节——而长度以 varint 编码存储在字符串数据的开头: ``` [varint len][string bytes][NUL] ``` 有多种 varint 编码方案。此方案用于无符号数,将小于 128 的数字编码为单字节。大多数字符串低于此阈值,因此 varint 长度只用一个字节,每个字符串大约节省 3 字节。 节点从 **144 → 112 字节** 缩小。 Str — 指针 + 长度,每字段 16 字节 char* s int64 len ArenaStr — 偏移量 + 长度,8 字节 u32 off u32 len ArenaStr — 仅偏移量,4 字节;长度存储在竞技场中 u32 off len 字符 0 注意:偏移量-长度字符串可以指向另一个字符串的切片,而长度前缀字符串则不行。我们之前没有这样做,所以此处不适用。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 1091.9 KB | **918.0 KB** | -15.9% | -44.2% | | 嵌套列表 | 1067.9 KB | 611.6 KB | **512.3 KB** | -16.2% | -52.0% | | GFM 表格 | 2926.0 KB | 1866.9 KB | **1543.6 KB** | -17.3% | -47.2% | | 实体 | 660.2 KB | 144.9 KB | **128.5 KB** | -11.3% | -80.5% | `List` 有一个 `start` 编号。`Heading` 有一个 `depth`。没有节点同时属于两者,因此它们合并为一个 `uint32_t startOrDepth`,由 `kind` 指明其含义。由于对齐填充,这并未缩小 `Node` 的大小,但我们仍然这样做,希望未来的优化能将其缩小到低于填充。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 918.0 KB | **918.0 KB** | +0.0% | -44.2% | | 嵌套列表 | 1067.9 KB | 512.3 KB | **512.3 KB** | +0.0% | -52.0% | | GFM 表格 | 2926.0 KB | 1543.6 KB | **1543.6 KB** | +0.0% | -47.2% | | 实体 | 660.2 KB | 128.5 KB | **128.5 KB** | +0.0% | -80.5% | ## 7. 压缩文本位置 (ed5e807 (https://github.com/kjk/gpui-cpp/commit/ed5e807)) https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#7-compressing-text-position-ed5e807-https-github-com-kjk-gpui-cpp-commit-ed5e807 每个 `Node` 都携带其在解析文本中的位置信息。这很昂贵,因为它存储为 `start` 和 `end` 字段,每个字段包含: - `uint32_t` 行 - `uint32_t` 列 - `uint32_t` 偏移量 那就是 4*3*2 = 24 字节。我假设此信息用于调试,因此对我来说不重要。我用两个 `uint32_t` 偏移量替换它,指向源 Markdown 字符串的 `srcStart` 和 `srcEnd`。我们可以根据此和源字符串重建行/列位置。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 918.0 KB | **828.0 KB** | -9.8% | -49.7% | | 嵌套列表 | 1067.9 KB | 512.3 KB | **462.2 KB** | -9.8% | -56.7% | | GFM 表格 | 2926.0 KB | 1543.6 KB | **1379.6 KB** | -10.6% | -52.9% | | 实体 | 660.2 KB | 128.5 KB | **120.0 KB** | -6.6% | -81.8% | ## 8. 进一步压缩文本位置 (6a558c4 (https://github.com/kjk/gpui-cpp/commit/6a558c4)) https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#8-further-compressing-text-position-6a558c4-https-github-com-kjk-gpui-cpp-commit-6a558c4 `srcEnd` 总是位于 `srcStart` 之后,因此我们可以对其进行增量编码并缩小为 `uint16_t`。如果它超过 64 KB 怎么办?我不在乎,我们将其存储为 65535。这是另一个由于填充而未能缩小结构体大小的案例。但请等待。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 828.0 KB | **828.0 KB** | +0.0% | -49.7% | | 嵌套列表 | 1067.9 KB | 462.2 KB | **462.2 KB** | +0.0% | -56.7% | | GFM 表格 | 2926.0 KB | 1379.6 KB | **1379.6 KB** | +0.0% | -52.9% | | 实体 | 660.2 KB | 120.0 KB | **120.0 KB** | +0.0% | -81.8% | 一些节点拥有存储为可增长向量的子节点。空向量在节点中占 24 字节。我们用压缩指针的环替换它:父节点指定其**最后一个**子节点,每个子节点指定**下一个**,最后一个子节点环绕回第一个。 向量:节点中 24 字节 + 单独的链接数组 ptr · len · cap kid0 kid1 kid2 spare spare 环:父节点中 4 字节,每个子节点 4 字节,无其他分配 parent kid0 kid1 kid2 lastKid 我们使用环而不是链表,因为解析器对子节点列表的唯一操作是追加。单链表需要遍历列表才能找到末尾,而环不需要。 节省:**96 → 80 字节**。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 828.0 KB | **619.0 KB** | -25.2% | -62.4% | | 嵌套列表 | 1067.9 KB | 462.2 KB | **308.8 KB** | -33.2% | -71.1% | | GFM 表格 | 2926.0 KB | 1379.6 KB | **898.7 KB** | -34.9% | -69.3% | | 实体 | 660.2 KB | 120.0 KB | **98.9 KB** | -17.6% | -85.0% | 注意:按索引访问子节点需要遍历环,因此循环中的索引将是二次方的。在我们的代码中,我们只请求第一个或最后一个。 对于表格,我们在每个节点上将列对齐信息存储在一个单独的向量中,尽管只有 `Table` 节点有它。这又是每个节点 24 字节。我们改为使用压缩指针,该指针指向列对齐信息的优化表示。有四种对齐方式(左、右、居中、无),因此一列需要 2 位: ``` [varint count][每列 2 位,每字节四列] ``` 整个列表在进入表格时就已知,因此被计数、分配一次并填充。对于一个 8 列的表格,这在竞技场中是 3 字节,在节点中是 4 字节偏移量。 节省:**80 → 60 字节**。节省超过 20 字节,因为随着持有指针的最后一个成员被移除,`alignof(Node)` 从 8 降到了 4。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 619.0 KB | **519.3 KB** | -16.1% | -68.5% | | 嵌套列表 | 1067.9 KB | 308.8 KB | **256.3 KB** | -17.0% | -76.0% | | GFM 表格 | 2926.0 KB | 898.7 KB | **710.3 KB** | -21.0% | -75.7% | | 实体 | 660.2 KB | 98.9 KB | **89.2 KB** | -9.8% | -86.5% | 块是按字节对齐推送的,而不是通过常规分配器(它四舍五入到 8,并会返回 varint 节省的确切大小)。 之前我们将 List 节点的独占字段 `start` 和 Heading 节点的 `depth` 融合为单个 `uint32_t`。我们将 Table 节点列对齐信息从之前的优化中也融合到同一个字段中。我们称它为 `uint32_t perKind`,由 `kind` 指明其值的类型。 节省:**60 → 56 字节**。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 519.3 KB | **483.9 KB** | -6.8% | -70.6% | | 嵌套列表 | 1067.9 KB | 256.3 KB | **233.7 KB** | -8.8% | -78.1% | | GFM 表格 | 2926.0 KB | 710.3 KB | **646.1 KB** | -9.0% | -77.9% | | 实体 | 660.2 KB | 89.2 KB | **86.1 KB** | -3.5% | -87.0% | 我们有 8 个字符串,并非所有节点都使用它们。我没有弄清楚最多需要多少字符串,而是在竞技场中创建了一个字符串链表。它们与常规字符串不同之处在于携带一个 4 字节的压缩指针指向竞技场中的下一个字符串,以及字符串的类型。 ``` [u32 next][u8 kind][varint len][len bytes][NUL] ``` 我们可以根据需要添加任意多种字符串,但只需为实际使用的字符串付费 + 每条记录 5 字节的开销。一些节点没有任何字符串。 8 个字段:每个节点 32 字节,其中 7 个在几乎所有节点上都是空的 value url title alt ident label lang meta 1 个字段:4 字节,并且仅针对节点实际携带的内容有一条记录 first next kind len characters 0 存储字符串额外花费 5 字节 · 不存储任何字符串的节点节省 28 字节 新记录添加在头部,因此存储是 O(1),而查找特定类型的遍历最多 8 步,并且几乎总是 1 或 0。就地增长仍然有效,因为记录是竞技场中最新内容的条件始终如一。 节省:**56 → 28 字节**。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 483.9 KB | **358.3 KB** | -26.0% | -78.2% | | 嵌套列表 | 1067.9 KB | 233.7 KB | **159.1 KB** | -31.9% | -85.1% | | GFM 表格 | 2926.0 KB | 646.1 KB | **402.9 KB** | -37.6% | -86.2% | | 实体 | 660.2 KB | 86.1 KB | **73.7 KB** | -14.4% | -88.8% | 碰巧我们有两个枚举: - 一个需要 6 位 - 另一个需要 2 位 我们将它们从 2 字节融合到 1 字节。因为这 1 字节的节省使其低于填充,所以我们节省了 4 字节,从 **28 → 24 字节**。 | 形状 | 起始 | 之前 | 之后 | 相比之前 | 相比起始 | | :--- | :--- | :--- | :--- | :--- | :--- | | 散文 | 1646.1 KB | 358.3 KB | **321.2 KB** | -10.4% | -80.5% | | 嵌套列表 | 1067.9 KB | 159.1 KB | **136.5 KB** | -14.2% | -87.2% | | GFM 表格 | 2926.0 KB | 402.9 KB | **341.9 KB** | -15.1% | -88.3% | | 实体 | 660.2 KB | 73.7 KB | **70.4 KB** | -4.5% | -89.3% | 此时,我决定不需要位置信息,因此将其移除。其他 Markdown 解析器不携带它,因此它似乎不太有用。我通过将其转换为步骤 12 中字符串列表的记录来减少 `perKind` 的开销——使用 varint 编码,带有自己的类型字节。List、Heading 或 Table 为其支付约 8 字节;每个其他节点则不支付任何费用,而一个字段在所有节点上都花费 4 字节。 节省:**24 → 16 字节**。出于安全考虑,竞技场分配器将分配对齐到 8 字节,但 16 字节的 `Node` 可以按 4 字节分配,我们正是这样做的。这减少了分配之间的浪费空间。 形状开始之前之后相比之前相比起始 散文1646.1 KB321.2 KB**308.0 KB**-4.1% -81.3% 嵌套列表1067.9 KB136.5 KB**128.8 KB**-5.6% -87.9% GFM 表格2926.0 KB341.9 KB**336.6 KB**-1.5% -88.5% 实体660.2 KB70.4 KB**67.6 KB**-4.0% -89.8% 至此,`Node` 的大小是 16 字节。这是 64 位指针大小。我们用尽了减少节点大小的方法。下一步是看看我们是否能缩小整个竞技场。 ## 13. 更小的竞技场 (7519571 (https://github.com/kjk/gpui-cpp/commit/7519571)) https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#13-smaller-arena-7519571 节点仅通过竞技场指针引用。如果我们能缩小这些指针,就能节省所有指向它们的指针的空间。 我们使用 32 位偏移量。如果我们将竞技场大小限制为 1 GB,偏移量就能放入 32 位。但等等,我们已经在使用 32 位偏移量了。 是的,但我们还有指针本身。每个 `ArenaStr` 和 `ArenaPtr` 都是 4 字节。然而,这些值仅在从竞技场加载到常规指针时需要。我们可以将它们保持为偏移量,直到使用它们的时候。这需要将解码推到使用点。 我们通过将 `ArenaStr` 和 `ArenaPtr` 替换为 `u32` 类型来做到这一点。解码发生在最后时刻。这减少了节点大小吗?没有,它们已经是 `u32` 了。 但它减少了字符串数据的大小。`ArenaStr` 曾是一个偏移量 + 长度在 8 字节内。现在它只是一个偏移量,4 字节。字符串数据本身包含长度作为 varint 前缀。 我们为字符串使用了多少空间? 散文:每个节点平均 0.5 个字符串 → 约 170,000 个字符串 → 约 500 KB 竞技场 表格:每个节点平均 2 个字符串 → 约 200,000 个字符串 → 约 1.5 MB 竞技场 字符串数据大小变化不大。 然而,这使得我们可以进行另一个优化。在步骤 9 中,我们使用环替换了向量。环节点是一个 4 字节压缩指针。如果我们使用 2 字节偏移量(限制竞技场为 64 KB),我们可以在节点中将子节点数量翻倍。 但我们限制竞技场为 64 KB 吗?不,我们不能。竞技场很大。 如果环节点使用 2 字节索引指向节点数组呢?节点数组是连续的。但节点不是连续的;它们在竞技场中。我们可以使它们连续。 如果我们使节点连续,我们可以使用 2 字节索引。但节点不是连续的。 如果我们将节点移动到单独的数组中,我们可以使用索引。但那需要更大的指针来间接引用。 我们陷入了循环。 ## 14. 更小的节点? (c74d29f (https://github.com/kjk/gpui-cpp/commit/c74d29f)) https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#14-smaller-node-c74d29f 我们使用 2 字节索引了吗?没有。我们再次查看了字符串。 我们有字符串类型和字符串数据。字符串类型是 `u8`。字符串数据是变长的。如果我们能将字符串内联到节点中,就能节省字符串指针的开销。 字符串内联到节点中吗?大多数字符串很短。但有些很长。我们不能将长字符串内联。 如果我们将短字符串内联呢?所有字符串都小于 16 字节吗?不是。但大多数字符串是。 我们可以将字符串内联到节点中,直到 16 字节。超过 16 字节,我们使用指针。 这减少了每个字符串 4 字节的指针。如果字符串是内联的,我们节省了 4 字节。如果字符串不是内联的,我们浪费了 16 字节的内联空间和 4 字节的指针。 平均节省多少?大多数字符串是短的。但并非所有。 我们尝试了。结果: 散文:每个节点平均 0.5 个字符串 → 约 170,000 个字符串 → 约 50% 短字符串 → 节省约 100 KB 表格:每个节点平均 2 个字符串 → 约 200,000 个字符串 → 约 30% 短字符串 → 节省约 120 KB 但节点大小增加了 8 字节(因为我们需要存储长度和标志)。净节省很少。 我们放弃了。 ## 15. 最后的改进 (7519571 (https://github.com/kjk/gpui-cpp/commit/7519571)) https://blog.kowalczyk.info/a-n8wf/optimizing-memory-use-in-markdown-parser.html#15-final-improvements-7519571 我们最后做了什么?我们压缩了位置信息。我们将其从 24 字节减少到 4 字节。我们压缩了字符串。我们压缩了向量。我们压缩了子节点。我们压缩了对齐信息。我们压缩了枚举。我们压缩了字段顺序。我们压缩了类型。 我们最后留下了什么? 散文:每个节点 0.5 个字符串 → 约

相似文章

@mem0ai: https://x.com/mem0ai/status/2054580022049198513

X AI KOLs Timeline

这篇文章解释了Codex CLI(OpenAI的开源编码代理)中的记忆机制。它描述了基于markdown文件的记忆架构、包含分阶段提取和整合的写入路径,以及使用关键词搜索的读取路径,所有设计都是为了可预测性和低检索成本。