一次解析分配了什么
主线位置:块主线和行内主线的内存账。
Parser::new_ext跑完第一遍,手里是一棵树和一个Allocations;Parser::next跑第二遍时再借几个栈、往表里添东西;Parser被 drop 时一起释放。这一页把这三个阶段的每一次分配都记下来,按来源归类。
flowchart TB newp["Parser::new_ext"] --> tree["树 起步 6144 B"] newp --> links["links 表 10240 B"] newp --> fp["第一遍扫行"] fp --> grow["树翻倍扩容"] fp --> small["spine 等小表"] nx["Parser::next"] --> stk["行内用的两个栈"] nx --> cows["cows 表扩容"] nx --> bx["少量 Boxed"] dropp["drop Parser"] --> free["一次释放"]
README 说这个库「a bare minimum of allocation and copying」。这一页用数字核对这句话:分配次数确实少,但每个解析器起步就要 16 KB,字节数主要花在树上。
实验方法
实验 crate exp-pulldown-cmark/events-exp 的 allocs(M2 Max、macOS 27、rustc 1.99.0,release,带调试信息)装了一个 #[global_allocator]:包一层 System,在每次 alloc 和 realloc 里沿 aarch64 的帧指针链(x29)往上走最多 28 层,把返回地址连同大小记进一张静态哈希表。走栈本身不分配,所以不会递归;记录结束、关掉开关之后才用 backtrace crate 把地址解析成函数名和行号(包括内联进来的帧),按最内层的几个 pulldown-cmark 帧归类。
- 分配次数:
alloc调用数;realloc单独计。 - 字节数:
alloc的大小加上realloc增长的部分,是累计申请量,不是同时占用量。 - 峰值:测量期间同时存活的字节数的最大值。
- 阶段:
new_ext(第一遍)、逐个取事件并立即丢弃(第二遍)、drop(parser);TextMergeStream和into_static各自另跑一遍完整解析。 - 正式测量前先解析一份小文档预热,排除进程里只发生一次的初始化。
Allocations 的六张表
第一遍和第二遍要交给事件的数据,不放在树节点里,而是放在 Allocations 的表里,节点只记一个下标(扁平数组上的树讲过为什么):
pub(crate) struct Allocations<'a> {
pub refdefs: RefDefs<'a>,
pub footdefs: FootnoteDefs<'a>,
links: Vec<(LinkType, CowStr<'a>, CowStr<'a>, CowStr<'a>)>,
cows: Vec<CowStr<'a>>,
alignments: Vec<Vec<Alignment>>,
headings: Vec<HeadingAttributes<'a>>,
}
| 表 | 谁往里写 | 谁取走 |
|---|---|---|
refdefs | 第一遍 parse_block 收链接引用定义,同名的先到先得(entry(label).or_insert) | 第二遍解析引用链接时查,每次 clone |
footdefs | 第一遍收脚注定义 | 第二遍查,记 use_count |
links | 第二遍 allocate_link,每个解析成功的链接、图片、自动链接一项 | item_to_event 用 take_link 取走 |
cows | 两遍都写:实体、围栏 info string、脚注标签、长串连字符(第一遍),行内代码、公式、脚注引用、跨行的行内 HTML(第二遍) | take_cow 取走 |
alignments | 第一遍识别到表格时 | Table 的 Start 用 take_alignment 取走 |
headings | 第一遍解析标题属性时 | Heading 的 Start 每次 clone |
取走(take_*)是 std::mem::replace 成空串或默认值,槽位不回收,Vec 也不缩:表只增不减,一直活到 Parser 被 drop。Allocations::new 只给 links 预留了 128 项,其余都从空开始。
例子文档:9 次分配
一份文档那 21 行(502 字节,ENABLE_TABLES)。阶段汇总:
input 502 bytes, 72 events, 70 after TextMergeStream
phase allocs reallocs frees bytes peak_live
new_ext (first pass) 7 0 0 17012 17012
iterate (second pass) 2 1 1 288 17292
drop Parser 0 0 8 0 11148
new_ext + TextMergeStream 10 3 10 17312 17292
new_ext + into_static 14 1 14 17489 17292
(drop Parser 一行的 peak_live 是进入这个阶段时的存量,不是新增。)每一次分配,[1] 是第一遍,[2] 是第二遍:
[1] 6144 <tree::Tree<parse::Item>>::with_capacity (tree.rs:65) <- firstpass::run_first_pass (firstpass.rs:34) <- <parse::Parser>::new_with_broken_link_callback (parse.rs:283)
[1] 10240 <parse::Allocations>::new (parse.rs:2079) <- firstpass::run_first_pass (firstpass.rs:37) <- <parse::Parser>::new_with_broken_link_callback (parse.rs:283)
[1] 32 <tree::Tree<parse::Item>>::push (tree.rs:112) <- <firstpass::FirstPass>::parse_block (firstpass.rs:92) <- <firstpass::FirstPass>::run (firstpass.rs:75)
[1] 96 <parse::Allocations>::allocate_cow (parse.rs:2088) <- <firstpass::FirstPass>::parse_line::{closure#0} (firstpass.rs:1112) <- firstpass::scalar_iterate_special_bytes::<<firstpass::FirstPass>::parse_line::{closure#0}, parse::Item> (firstpass.rs:2456)
[1] 8 scanners::scan_table_head (scanners.rs:692) <- <firstpass::FirstPass>::parse_line::{closure#0} (firstpass.rs:831) <- firstpass::scalar_iterate_special_bytes::<<firstpass::FirstPass>::parse_line::{closure#0}, parse::Item> (firstpass.rs:2456)
[1] 96 <parse::Allocations>::allocate_alignment (parse.rs:2106) <- <firstpass::FirstPass>::parse_line::{closure#0} (firstpass.rs:840) <- firstpass::scalar_iterate_special_bytes::<<firstpass::FirstPass>::parse_line::{closure#0}, parse::Item> (firstpass.rs:2456)
[1] 396 <firstpass::FirstPass>::parse_block (firstpass.rs:380) <- <firstpass::FirstPass>::run (firstpass.rs:75) <- firstpass::run_first_pass (firstpass.rs:43)
[2] 128 <parse::InlineStack>::push (parse.rs:1742) <- <parse::Parser>::handle_emphasis_and_hard_break (parse.rs:1094) <- <parse::Parser>::handle_inline (parse.rs:391)
[2] 64 <parse::LinkStack>::push (parse.rs:1840) <- <parse::Parser>::handle_inline_pass1 (parse.rs:617) <- <parse::Parser>::handle_inline (parse.rs:390)
[2] R 96->192 <parse::Allocations>::allocate_cow (parse.rs:2088) <- <parse::Parser>::make_code_span (parse.rs:1435) <- <parse::Parser>::handle_inline_pass1 (parse.rs:587)
逐行对账(行号是 0.13.5 的源码行):
- 6144 = 128 × 48。
run_first_pass按max(128, text.len() / 32)估节点数,502 字节估出 128;一个Node<Item>48 字节。这份文档实际用了 70 项。 - 10240 = 128 × 80。
links预留的 128 项,每项是(LinkType, CowStr, CowStr, CowStr),实测 80 字节。整份文档只有 1 个链接。 - 32:
spine第一次push,Vec<TreeIndex>起步 4 项 × 8 字节。 - 96:
cows第一次push,4 项 × 24 字节。推进去的是&解出的&,一个借静态表的Borrowed,字符串本身不分配,分配的是表。 - 8 和 96:表格。
scan_table_head收集每列对齐的Vec<Alignment>(起步 8 项 × 1 字节),交给alignments(起步 4 项 × 24 字节)。 - 396:
refdefs第一次插入(firstpass.rs第 380 行的entry(label).or_insert),一个HashMap的第一块桶。 - 128 和 64:第二遍的分隔符栈
InlineStack(4 项 × 32 字节)和链接栈LinkStack(4 项 × 16 字节)第一次push。这两个栈放在Parser里复用,整份文档只分配一次。 - 96→192:第 5 个
CowStr进cows(行内代码Parser::next),表从 4 项长到 8 项。
两遍合计 9 次分配、1 次 realloc,峰值 17 292 字节,是输入的 34 倍。其中 16 384 字节是 new_ext 一开头的两块,和文档内容无关。
最早的 FirstPass::new 用 Tree::new(),节点数组从空开始一路翻倍。提交 fa89e46(「Reserve space for nodes in advance」)加上了 max(128, text.len() / 32) 和那句注释「This is a very naive heuristic for the number of nodes we'll need.」,同时把 Allocations 从 Default 改成手写的 new,给 cows 预留 128 项;同一个 PR 里稍后的提交 ee9c0ba(「Improve ergonomics of Allocations somewhat」)把这 128 项从 cows 挪给了 links。两个提交都随 PR #274(「Smaller tree」)首次发布于 0.5.0。那时 links 的元素只有 (LinkType, CowStr, CowStr);PR #436 的提交 2ea7808(「add link identifiers into emitted tags」,首次发布于 0.10.0)加了第三个 CowStr 存引用标签,元素长到今天的 80 字节,预留的 128 项也跟着变成 10 KB。
本站语料:1099 篇
把本站内容仓库的 1099 篇 Markdown(去掉 frontmatter 后 17 971 168 字节,不做本站渲染前的 ::: 提示块转换)逐篇解析,选项用本站 mdx.rs 的七个:
1099 files, 17971168 bytes, 702096 events, 700582 after TextMergeStream
phase allocs reallocs frees bytes peak_live
new_ext (first pass) 6300 2948 153 78591312 329840
iterate (second pass) 2155 4182 1329 6324827 352320
drop Parser 0 0 6973 0 35308
new_ext + TextMergeStream 8914 8318 8914 84971376 352320
new_ext + into_static 161939 7130 161939 99091917 352800
平均每篇 7.7 次分配、6.5 次 realloc;逐篇统计,分配次数中位数 8、最多 18。把累计字节按最内层的 pulldown-cmark 函数归类:
| 来源 | 阶段 | 分配 | realloc | 累计字节 |
|---|---|---|---|---|
树扩容(Tree::create_node) | 第一遍 | 0 | 1227 | 39 332 160 |
树的初始容量(Tree::with_capacity) | 第一遍 | 1099 | 0 | 27 508 704 |
links 预留(Allocations::new) | 第一遍 | 1099 | 0 | 11 253 760 |
cows 表(allocate_cow) | 第二遍 | 16 | 4127 | 6 100 224 |
cows 表(allocate_cow) | 第一遍 | 1083 | 1676 | 391 680 |
分隔符栈(InlineStack::push) | 第二遍 | 842 | 0 | 107 776 |
链接栈(LinkStack::push) | 第二遍 | 1098 | 0 | 70 272 |
表格对齐(allocate_alignment、scan_table_head) | 第一遍 | 1802 | 42 | 68 800 |
spine(Tree::push) | 两遍 | 1099 | 12 | 35 552 |
links 超过 128 项(allocate_link) | 第二遍 | 0 | 3 | 30 720 |
行内代码的分隔符表(CodeDelims::insert) | 第二遍 | 163 | 7 | 14 528 |
跨行行内代码的 String(make_code_span) | 第二遍 | 36 | 36 | 1 019 |
树占了第一遍字节的 85%,links 的固定预留占 14%;CowStr::Boxed 只有那 36 个跨行的行内代码,一共 1 KB。refdefs 的 HashMap 在整个语料里一次也没分配过。cows 在第一遍的 1083 次分配几乎都来自围栏代码块:每个代码块的 info string(哪怕是借用的 rust)都要进 cows。
估算偏小
第一遍的字节一半花在扩容上,因为 text.len() / 32 对这份语料估少了。用一份打了补丁、能读出节点数的源码副本(exp-pulldown-cmark/events-vendor-pcm,只加了一个返回 nodes.len() 的方法)数了每篇的节点:
docs 1099 bytes per node median 18.1 p10 13.5 p90 24.3
total bytes 17971168 nodes 1012782 events 702096 bytes/node 17.7 events/node 0.69
nodes exceed estimate 985
平均 17.7 字节一个节点,不到 32 的一半。1099 篇里 985 篇超出了初始容量:746 篇翻倍一次,236 篇两次,3 篇三次。这份语料行内代码多(17 万个 Code 事件,每个在第一遍是两个 MaybeCode 节点加中间的文本),表格多(2.6 万个单元格)。作为对照,英文的 CommonMark 规范 spec.txt(183 524 字节,不开选项)是 21.3 字节一个节点,初始估 5735 项、实际 8622 项,也扩容了一次。
翻倍扩容的代价是复制和空闲:峰值最高的一篇 53 170 字节,节点 3755 个,数组却已经翻到 6644 项(1661 × 4),近一半是空的,这篇的峰值 352 320 字节,是输入的 6.6 倍。逐篇看,峰值与输入之比中位数 4.6(10% 分位 3.65,90% 分位 9.6);最小的一篇 323 字节,峰值 16 576 字节,51 倍,几乎全是那 16 KB 固定开销。
问: 第二遍用 take_cow、take_link 把字符串交给事件了,内存是不是边走边降?
答: 不降。取走只是把槽位换成空串,Vec 的容量不变;树的节点一个也不删,第二遍还在原地改写它们(MaybeCode 变成 Code、方括号变成 Link)。所以峰值出现在第二遍,等于第一遍建好的全部结构加上第二遍新长出来的 cows、两个栈。例子文档的 8 次释放全部发生在 drop Parser 那一刻;第二遍唯一的一次释放是 Start(Table) 事件带走的对齐 Vec,随事件一起被丢掉。
TextMergeStream 多了什么
TextMergeStream 是 TextMergeWithOffset 套一个把偏移填成 0..0 的适配器。合并的核心在 TextMergeWithOffset::next:
(
Some((Event::Text(last_text), last_offset)),
Some((Event::Text(next_text), next_offset)),
) => {
// We need to start merging consecutive text events together into one
let mut string_buf: String = last_text.into_string();
string_buf.push_str(&next_text);
// ...
break Some((
Event::Text(CowStr::Boxed(string_buf.into_boxed_str())),
offset,
));
它总是先看一个事件:手里攥着上一个,再取下一个,两个都是 Text 才合并。合并必然分配:into_string 把第一段复制成 String,后面每段 push_str 可能扩容,最后 into_boxed_str 把多余容量缩掉,又是一次 realloc。例子文档里引用块那句 和 A、&、B, 被拼成一个,分配记录是:
[4] 6 <strings::CowStr>::into_string (strings.rs:0) <- <utils::TextMergeWithOffset<utils::DummyOffsets<parse::Parser>> as core::iter::traits::iterator::Iterator>::next (utils.rs:91) <- <utils::TextMergeStream<parse::Parser> as core::iter::traits::iterator::Iterator>::next (utils.rs:39)
[4] R 6->12 <utils::TextMergeWithOffset<utils::DummyOffsets<parse::Parser>> as core::iter::traits::iterator::Iterator>::next (utils.rs:92) <- <utils::TextMergeStream<parse::Parser> as core::iter::traits::iterator::Iterator>::next (utils.rs:39)
[4] R 12->11 <utils::TextMergeWithOffset<utils::DummyOffsets<parse::Parser>> as core::iter::traits::iterator::Iterator>::next (utils.rs:109) <- <utils::TextMergeStream<parse::Parser> as core::iter::traits::iterator::Iterator>::next (utils.rs:39)
一次 6 字节的分配,长到 12,再缩回 11 字节( 和 A&B, 的 UTF-8 长度)。语料上合计多了 459 次分配、1188 次 realloc、55 237 字节,事件少了 1514 个,正好等于「紧跟在另一个 Text 后面的 Text」的个数。
分配不多,时间却明显变长。实验 census 不装计数分配器,对整个语料各跑 20 次取最小(测量时 uptime 负载 3.75):
lookahead-only adapter 47.59 ms (702096)
min of 20: plain 42.73 ms (702096 events, 421 MB/s), TextMergeStream 54.06 ms (700582), plain+into_static 54.30 ms (702096)
| 方式 | 时间 | 比直接迭代 |
|---|---|---|
直接迭代 Parser | 42.73 ms | |
| 只缓冲一个事件、从不合并的适配器 | 47.59 ms | +11% |
TextMergeStream | 54.06 ms | +27% |
每个事件 into_static | 54.30 ms | +27% |
459 次小分配解释不了 11 ms。只做「先看一个」缓冲的适配器已经多出 4.9 ms:每个 80 字节的事件(带偏移是 96 字节)要多搬进搬出一个 Option。TextMergeStream 在此之上还多一层 DummyOffsets,每个事件都过一遍四路 match。想要合并文本又在意这 27%,可以只在自己关心的那几种事件附近手工拼接。改写事件流的更多做法见改写事件流。
Parser::new_ext("", Options::empty()) 分配几次、多少字节?空字符串,没有一行要扫。答案在文末。动脑筋答案
input 0 bytes, 0 events, 0 after TextMergeStream
phase allocs reallocs frees bytes peak_live
new_ext (first pass) 2 0 0 16384 16384
iterate (second pass) 0 0 0 0 0
drop Parser 0 0 2 0 10240
...
-- every allocation (R = realloc old->new)
[1] 6144 <tree::Tree<parse::Item>>::with_capacity (tree.rs:65) <- firstpass::run_first_pass (firstpass.rs:34) <- <parse::Parser>::new_with_broken_link_callback (parse.rs:283)
[1] 10240 <parse::Allocations>::new (parse.rs:2079) <- firstpass::run_first_pass (firstpass.rs:37) <- <parse::Parser>::new_with_broken_link_callback (parse.rs:283)
2 次,16 384 字节:树至少 128 项(6144 字节),links 固定 128 项(10 240 字节),和输入长度无关。只有一个单词的 hello\n 是 3 次、16 416 字节,多出来的 32 字节是 spine 第一次压栈。对一个要反复解析大量短字符串的程序(比如逐条渲染评论),这 16 KB 是每条都要付的。
小结
- 解析器起步就分配两块:树
max(128, len/32)× 48 字节,links128 × 80 = 10 240 字节;空字符串也是 16 384 字节。来自 PR #274(0.5.0),links的元素在 PR #436(0.10.0)长到 80 字节。 - 例子文档(502 字节)两遍共 9 次分配、1 次
realloc,峰值 17 292 字节;本站语料每篇中位 8 次分配,次数确实少。 - 字节主要在树上:本站语料 17.7 字节一个节点,
len / 32估少了,90% 的文档要翻倍扩容,峰值中位数是输入的 4.6 倍。 Allocations的表只增不减,take_*只换空值;内存在 drop 时一次释放。CowStr::Boxed在语料上只有 36 次、1 KB。TextMergeStream每次合并新分配一个String,语料上多 459 次分配,但时间多 27%,主要花在逐个缓冲和搬动 80 字节的事件上。