优化Markdown解析器的内存使用
摘要
这篇博客文章详细介绍了优化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 个字符串 → 约
相似文章
AST-grep 如何使用 Rust 重写 Tree-sitter 并使其速度提升 30%
ast-grep 用 Rust 重写了 Tree-sitter 的 C 核心,实现了高达 30% 的解析速度提升和 22% 的端到端性能提升,但内存使用略有增加。
使用数据导向设计工程化高性能解析器
本文介绍了如何通过关注内存布局和数据导向设计来设计高性能解析器,并以用Zig编写的Yuku JavaScript/TypeScript解析器为例。
@mem0ai: https://x.com/mem0ai/status/2054580022049198513
这篇文章解释了Codex CLI(OpenAI的开源编码代理)中的记忆机制。它描述了基于markdown文件的记忆架构、包含分阶段提取和整合的写入路径,以及使用关键词搜索的读取路径,所有设计都是为了可预测性和低检索成本。
我曾以为Markdown记忆对智能体足够用了。结果它变成了提示债。
作者反思了将扁平化Markdown文件用作智能体长期记忆的局限性,随着记忆增长,这导致了提示债,并提倡使用基于图的记忆表示方法,动态检索相关上下文。
@charliermarsh: /目标:寻找能让你解析器提速20-30%的简单单行优化
Charlie Marsh 分享了一个个人目标:寻找能让解析器提速20-30%的简单单行优化。