为什么是 pull parser
主线位置:三条主线的外面。本页回答 pulldown-cmark 交给你的是什么、这种形态能做和不能做什么;README 说的「pull」落到代码上是哪一段;最后用同样的输入对比 comrak(建 AST)和 markdown-rs(建 mdast)的速度、内存和输出。
flowchart TB md["Markdown 文本"] --> fp["第一遍 建块树"] fp --> it["Parser 迭代器"] it -->|next| ev["一个 Event"] ev --> you["你的代码 或 push_html"] md --> cast["comrak 建 AST"] cast --> chtml["遍历 AST 写 HTML"] md --> mev["markdown-rs 攒全部事件"] mev --> mout["编译成 HTML 或 mdast"]
README 怎么说
README 开头一句定位:「This library is a pull parser for CommonMark」,后面专门有一节「Why a pull parser?」。要点有三条:
- 比建树省内存,又比回调好用。 「Pull parsing has become popular for XML, especially for memory-conscious applications, because it uses dramatically less memory than constructing a document tree, but is much easier to use than push parsers.」push 解析器(SAX 那样的回调)要求使用者在回调之间自己维护状态,pull 解析器把控制权留在调用方的循环里。
- 最通用。 「Pull parsing is in some sense the most versatile architecture. It's possible to drive a push interface, also with minimal memory, and quite straightforward to construct an AST.」有了事件流,接回调或建树都是在它上面加一层。
- 自带源码位置。
into_offset_iter()产出(Event, Range<usize>),每个事件都能映射回原文的字节范围。
落到 API 上,Parser 直接实现 Iterator<Item = Event<'input>>,而且是 FusedIterator。事件是一个枚举:Start(Tag) 和 End(TagEnd) 成对出现,中间是 Text、Code、SoftBreak 这些叶子事件,变体的细节见事件模型。
事件流能做什么
因为它就是一个迭代器,标准库的适配器都能用。README 给的三个例子:
// 软换行全部变成硬换行
let parser = parser.map(|event| match event {
Event::SoftBreak => Event::HardBreak,
_ => event
});
另外两个是在 Text 上做替换、数 Start 和 End 求最大嵌套深度。这类变换的共同点是只看当前事件,或者只需要一个计数器,事件流上一遍做完,不用额外内存。HTML 写出器也只是这样一个消费者:push_html 接受任何 Iterator<Item = Event>,所以改写和输出可以串在一条链上,见改写事件流。
做不到、或者要自己补的:
- 需要回看或前瞻的变换。 在文档开头插一份目录,得先看完所有标题;给标题生成锚点要拿到标题全文,而标题的文字是
Start(Heading)和End之间的若干个事件。做法是自己缓冲:collect成Vec<Event>,或者在Start和End之间攒一段。 - 改结构。 事件流里没有「节点」可以挪动或删除,删掉一个
Start就得同时删掉配对的End和中间的一切,得自己数层数。comrak 的 AST 节点可以原地改,它还导出了format_commonmark,能把改过的树写回 Markdown;pulldown-cmark 没有反向的写出器。 - 按事件做文本处理时注意切分。 一段连续的文字可能被切成几个
Text:例子文档里A&B,产出Text("A")、Text("&")、Text("B,")三个事件(见一份文档)。按事件搜索替换会漏掉跨事件的匹配,TextMergeStream就是为此存在的。
pull 的是哪一遍
README 没说的是:第一个事件出来之前,整份文档已经被扫完了一遍。 Parser::new_with_broken_link_callback 的第一行就是:
let (mut tree, allocs) = run_first_pass(text, options);
tree.reset();
run_first_pass 逐行扫完全文,建出一棵 Tree<Item>:所有块(段落、列表、引用、代码块、表格)都在里面,块里的文本也已经切成节点链,可能是行内语法的字节记成了 MaybeEmphasis、MaybeCode 这类占位。firstpass.rs 的模块注释说得很直白:「The first pass resolves all block structure, generating an AST. Within a block, items are in a linear chain with potential inline markup identified.」
真正「拉」的是第二遍:每次 next,Parser 沿树走一步;走到占位节点时才调 handle_inline 解析这一个块里剩下的行内语法。所以 pull 的含义是按需解析行内、按需产出事件,不是边读输入边解析。输入必须一次给全(&'input str),不能喂一个 Read。
为什么非要先扫一遍?CommonMark 有两件事必须看到后文才能定:
- 链接引用定义可以写在使用它的地方之后。 例子文档里
[CommonMark 规范][spec]在第 4 行,[spec]: ...在第 21 行。第一遍把所有定义收进Allocations的refdefs,第二遍遇到[...][spec]时才查得到。 - 列表是紧凑还是松散,要看到列表末尾才知道。 任何两项之间隔了空行,整个列表的每一项都要包
<p>,第一项的输出取决于后面。
两个原因都只关乎块结构,所以第一遍只定块;行内语法只影响所在的块,可以推迟到迭代时。
问: README 说 pull parsing「uses dramatically less memory than constructing a document tree」,可 pulldown-cmark 自己也建了一棵树。这两句矛盾吗?
答: 不完全矛盾,但省的不是「树」本身。pulldown-cmark 的树是一个 Vec<Node<Item>>,每个节点 48 字节(start、end、ItemBody、两个 Option<TreeIndex>),只存字节范围不存字符串,没有父指针,不分配单个节点;整份文档的内容仍然是你传进来的那个 &str。下面的实测里,spec.txt(205 KB)建出 9719 个节点,峰值堆内存 674 KB,整个解析只分配了 70 次。comrak 的 AST 节点是 arena 里的 Node<RefCell<Ast>>,文本存成 Cow<'static, str>,借不了输入,只能复制出来;同一份输入峰值 2.4 MB、分配 11111 次。省下来的是这部分;内存仍然和文档长度成正比,不是常数。
实测:三个库,同样的输入
实验 intro-bench(release,opt-level = 3,不开 LTO):pulldown-cmark =0.13.5、comrak 0.59.0、markdown 1.0.0(markdown-rs)。三者都只开 CommonMark,不开任何扩展,并且允许原样输出 HTML(comrak 设 render.r#unsafe = true,markdown-rs 设 allow_dangerous_html 和 allow_dangerous_protocol),这是 spec 的期望输出所要求的。每个库测两种操作:
// 只解析
fn pd_events(s: &str) -> usize { Parser::new_ext(s, Options::empty()).count() }
fn cm_parse(s: &str, o: &comrak::Options) -> usize {
let arena = comrak::Arena::new();
comrak::parse_document(&arena, s, o).descendants().count()
}
fn md_mdast(s: &str) -> usize { /* markdown::to_mdast,再数一遍节点 */ }
// 解析并写出 HTML
fn pd_html(s: &str) -> String { /* html::push_html */ }
fn cm_html(s: &str, o: &comrak::Options) -> String { comrak::markdown_to_html(s, o) }
fn md_html(s: &str, o: &markdown::Options) -> String { markdown::to_html_with_options(s, o).unwrap() }
输入有两份:
- spec.txt:pulldown-cmark 仓库 tag 上的
third_party/CommonMark/spec.txt(CommonMark 0.31.2),原样当成一篇 Markdown,204815 字节。它本身就是合法的 CommonMark,652 个例子是 info string 为example的围栏代码块。 - 本站语料:内容仓库
docs/下全部 1099 篇 Markdown,共 18522807 字节(最大一篇 70018 字节),逐篇解析,计一批的总和。
spec 例子:三个都是 652 / 652
先按 build.rs 的方式把 spec.txt 拆成 652 个例子,每个例子分别交给三个库,用 pulldown-cmark 测试里的同一个规范化函数(html_standardize)比较:
normalization: html_standardize (tests/lib.rs)
spec.txt examples: 652
pulldown-cmark 0.13.5 Options::empty(): 652 pass, fails []
comrak 0.59 default+unsafe: 631 pass, fails [12, 14, 27, 41, 91, 209, 210, 211, 343, 352, 359, 363, 380, 385, 395, 508, 590, 619, 620, 624, 632]
markdown-rs 1.0 default+dangerous: 631 pass, fails [12, 14, 27, 41, 91, 209, 210, 211, 343, 352, 359, 363, 380, 385, 395, 508, 590, 619, 620, 624, 632]
另外两个库失败的 21 个例子完全一样,原因也一样:正文里的 "。spec 的期望输出写的是 ",comrak 和 markdown-rs 输出 ",pulldown-cmark 的 escape_html_body_text 在正文里不转义 "(属性值里用的 escape_html 才转),所以恰好和期望逐字相同。两种写法在浏览器里等价。把 " 当成 " 再比:
normalization: html_standardize + " as "
spec.txt examples: 652
pulldown-cmark 0.13.5 Options::empty(): 652 pass, fails []
comrak 0.59 default+unsafe: 652 pass, fails []
markdown-rs 1.0 default+dangerous: 652 pass, fails []
三个库在 CommonMark 0.31.2 的例子上全部通过。pulldown-cmark 自己的测试对这 652 条打开了表格、删除线、数学等一串扩展(见怎么读这一卷),这里用 Options::empty() 也是 652 条全过。
速度
每格是 release 下的最小值:spec.txt 一轮里重复 50 次,语料一轮里重复 10 次,共跑 4 轮,再取 4 轮里的最小;跑的时候 1 分钟负载在 4.7 到 12.4 之间:
| 操作 | spec.txt(205 KB) | 吞吐 | 语料 1099 篇(18.5 MB) | 吞吐 |
|---|---|---|---|---|
| pulldown-cmark:迭代全部事件 | 0.444 ms | 461 MB/s | 41.4 ms | 447 MB/s |
comrak:parse_document | 1.234 ms | 166 MB/s | 80.3 ms | 231 MB/s |
markdown-rs:to_mdast | 35.0 ms | 5.9 MB/s | 970 ms | 19.1 MB/s |
pulldown-cmark:push_html | 0.726 ms | 282 MB/s | 64.8 ms | 286 MB/s |
comrak:markdown_to_html | 1.640 ms | 125 MB/s | 109.9 ms | 169 MB/s |
markdown-rs:to_html_with_options | 35.3 ms | 5.8 MB/s | 976 ms | 19.0 MB/s |
只解析时 pulldown-cmark 比 comrak 快 2.8 倍(spec.txt)和 1.9 倍(语料),比 markdown-rs 快 79 倍和 23 倍;加上写 HTML,差距缩到 2.3 倍和 1.7 倍,因为写出和转义的开销三家都有。markdown-rs 的两种输出耗时几乎一样:它的 to_html_with_options 和 to_mdast 都是先 parser::parse 出整份文档的事件 Vec,再分别编译,时间花在共同的解析上。它在 spec.txt 上比在中文语料上慢得多(5.8 对 19 MB/s),spec.txt 里大量的 ASCII 标点和代码块是主要区别,具体原因本卷没有追。
Parser::new_ext 返回时(还没调用 next)已经花掉了完整解析时间的几成?完整解析指 Parser::new_ext(s, Options::empty()).count(),上面测得 0.444 ms。提示:构造函数里跑的是哪一遍?第一个事件又要等什么?答案在文末。
内存
用一个计数分配器(包住 System,记当前字节数、峰值、分配次数和累计字节)。「峰值」是调用期间存活堆内存的最大值,减去调用前的基线;语料一栏是逐篇解析、每篇的结果用完就丢,所以峰值是单篇里最大的那次:
| 操作 | spec.txt 峰值 | 分配次数 | 最大单篇(70 KB)峰值 | 语料峰值 | 语料分配次数 |
|---|---|---|---|---|---|
| pulldown-cmark:迭代全部事件 | 674270 | 70 | 245088 | 355168 | 13508 |
comrak:parse_document | 2433026 | 11111 | 635792 | 653231 | 726302 |
markdown-rs:to_mdast | 19840080 | 80898 | 3942272 | 3942272 | 4768996 |
pulldown-cmark:push_html | 936414 | 91 | 406880 | 432224 | 26274 |
comrak:markdown_to_html | 2632772 | 12542 | 783262 | 783262 | 768020 |
markdown-rs:to_html_with_options | 19840080 | 87231 | 3942272 | 3942272 | 4695403 |
pulldown-cmark 在 spec.txt 上的 674 KB 几乎全是那棵树。用本卷那份暴露 Tree 的本地拷贝数一下:第一遍建出 9719 个节点(Options::empty()),size_of::<Node<Item>>() 是 48;run_first_pass 按 max(128, 204815 / 32) = 6400 预分配,装不下,Vec 翻倍到 12800 个节点,即 614400 字节。剩下的几十 KB 是 spine、Allocations 和行内处理用的几个栈。这个预估在注释里自称「a very naive heuristic」;对这份 spec.txt 它小了一半,代价是一次 realloc,也让峰值多出四成空槽。分配次数少(70 次)是因为节点不单独分配,文本不复制,CowStr 只在转义、实体这类地方才分配,见一次解析分配了什么。
push_html 比只迭代多出 262144 字节,正好是 256 KiB:输出的 String 没有预留容量,边写边翻倍,停在这个容量上。
输出一致吗:本站 1099 篇
同样只开 CommonMark、同样两步规范化,逐篇比较三个库的 HTML:
normalization: html_standardize + " as "
corpus files: 1099 (18522807 bytes), CommonMark only, normalized HTML identical:
pulldown == comrak: 1097
pulldown == markdown-rs: 1083
comrak == markdown-rs: 1081
all three: 1081
不一致的 18 篇归结为三种输入。把它们缩成最小复现,再交给仓库 third_party/commonmark.js/ 里打包的 commonmark.js(CommonMark 的参考实现,用 node 跑)当裁判:
| 输入 | pulldown-cmark | comrak 0.59 | markdown-rs 1.0 | commonmark.js |
|---|---|---|---|---|
# #a(标题文字以 # 开头;15 篇里的 #[derive(...)]、#1234) | <h1>#a</h1> | <h1>#a</h1> | <h1>a</h1> | <h1>#a</h1> |
- a、- b,后面空两行再接段落(1 篇) | 紧凑列表 | 紧凑列表 | 每项包 <p> | 紧凑列表 |
`` `b` `c`(先出现一个配不上的两个反引号;2 篇) | 两个 <code> | 第二个没识别,原样输出反引号 | 两个 <code> | 两个 <code> |
三个最小复现里 pulldown-cmark 都和参考实现一致,分歧是另外两个库的问题,而且 spec 的 652 个例子都没覆盖到。pulldown-cmark 这一侧并非碰巧:仓库的 fuzz/ 里有一个差分 fuzz 目标 commonmark_js,把同一输入同时交给 commonmark.js 和 pulldown-cmark、比较事件序列,这类边角会被它持续撞到。
两遍不是后来的优化,第一个提交之后第八天就有了。8898f9c(2015-04-26,「Two pass structure」,早期直接提交到主干,没有 PR)的说明是:「Take two passes over the source. The first pass collects events into a vec. The second will eventually resolve tight/loose lists, and also link references」。那时的 Parser::new 在构造时就把 RawParser 跑到底、把每个事件推进 event_vec,next 只是从这个 Vec 里往外取,内存上和建树没有区别。之后的版本改成第一遍带一个内部选项 OPTION_FIRST_PASS 再跑一次 RawParser,丢掉事件、只收集链接定义等信息;2019 年的 v0.3.0 换成了现在的设计,第一遍直接建块树,行内留给 next。那个内部选项占着 Options 的第 0 位,直到 14627f6(「Remove first pass option」,随 #343「Simplify Html and Options」合入,首次发布于 v0.6.0)才删掉,所以今天的 ENABLE_TABLES 仍是 1 << 1。8898f9c 在仓库里最早的 tag v0.0.5(2016-01-02)里就有。两遍的理由一直没变:紧凑与松散列表、链接引用定义,都要看到后文。
和 comrak、markdown-rs 比,差在形态
| pulldown-cmark | comrak | markdown-rs | |
|---|---|---|---|
| 交给你的 | Iterator<Item = Event> | arena 里的 AST(&AstNode,节点可改) | HTML 字符串,或 mdast 树(to_mdast);内部事件不公开 |
| 中间结构 | 块树 + 行内按需 | 完整 AST | 整份文档的事件 Vec |
| 回写 Markdown | 没有 | format_commonmark | 没有(mdast 可交给别的工具) |
| 扩展 | Options 的 15 个位 | Options 里的布尔开关 | Constructs 里的布尔开关 |
| 实测解析(spec.txt) | 0.444 ms,674 KB | 1.234 ms,2.4 MB | 35.0 ms,19.8 MB |
选哪个取决于要做什么:只渲染成 HTML、或者做逐事件的改写,事件流最省;要在树上来回挪节点、再写回 Markdown,用 comrak 更直接;要和 unified/remark 生态的 mdast 对接,用 markdown-rs。本站把事件流直接转成 Dioxus 的 RSX,不经过 HTML 字符串,见 08 在本站中。设计上的取舍在 06 设计取舍 汇总。
动脑筋答案
约六成。intro-bench 的 split 子命令分别计时只构造、构造加一次 next、构造加全部 next(spec.txt 取 50 次最小,语料取 10 次最小),跑两轮:
Parser::new_ext only spec 0.272 ms corpus 21.39 ms
new_ext + first next() spec 0.275 ms corpus 21.47 ms
new_ext + all events spec 0.457 ms corpus 42.39 ms
Parser::new_ext only spec 0.275 ms corpus 21.51 ms
new_ext + first next() spec 0.270 ms corpus 21.34 ms
new_ext + all events spec 0.455 ms corpus 42.77 ms
- 只构造就要 0.27 ms,占 0.455 ms 的 60%;在本站语料上是 21.4 / 42.4,约一半。
- 「构造加一次
next」和「只构造」一样:拿到第一个事件几乎不花额外时间,因为块已经全部定好,第一个事件是第一个块的Start。 - 剩下的四成到五成是第二遍:沿树走、在
handle_inline里解析行内、生成Event。两份输入的比例不同(六成对五成),说明两遍的相对开销随内容变化;本卷没有再拆是哪类块造成的。
小结
- pulldown-cmark 交给你一个
Iterator<Item = Event>;逐事件的改写和 HTML 输出都是这个迭代器的消费者,需要回看的变换要自己缓冲。 - 「pull」只针对第二遍:
Parser::new_ext里第一遍已经扫完全文、建好块树,因为链接定义和列表松紧都要看后文。输入必须一次给全。 - 树是 48 字节一个节点的
Vec,只存字节范围;spec.txt 上 9719 个节点、峰值 674 KB、70 次分配,预估容量偏小导致一次翻倍。 - CommonMark 0.31.2 的 652 个例子三个库都全过(正文
"的写法差异规范化后);只解析时 pulldown-cmark 比 comrak 快 2 到 3 倍,比 markdown-rs 快 20 到 80 倍。 - 本站 1099 篇里三者输出有 18 篇不同,缩成的三个最小复现里 pulldown-cmark 都和 commonmark.js 一致:markdown-rs 1.0 吞掉标题开头的
#、把后跟两个空行的紧凑列表当成松散;comrak 0.59 在一个配不上的反引号串之后漏认代码段。