Rust in Everything
基于 pulldown-cmark 0.13.5

为什么是 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&amp;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>,第一项的输出取决于后面。

两个原因都只关乎块结构,所以第一遍只定块;行内语法只影响所在的块,可以推迟到迭代时。

📝NOTE
没有蠢问题

问: 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 输出 &quot;,pulldown-cmark 的 escape_html_body_text 在正文里不转义 "(属性值里用的 escape_html 才转),所以恰好和期望逐字相同。两种写法在浏览器里等价。把 &quot; 当成 " 再比:

normalization: html_standardize + &quot; 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 ms461 MB/s41.4 ms447 MB/s
comrak:parse_document1.234 ms166 MB/s80.3 ms231 MB/s
markdown-rs:to_mdast35.0 ms5.9 MB/s970 ms19.1 MB/s
pulldown-cmark:push_html0.726 ms282 MB/s64.8 ms286 MB/s
comrak:markdown_to_html1.640 ms125 MB/s109.9 ms169 MB/s
markdown-rs:to_html_with_options35.3 ms5.8 MB/s976 ms19.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 标点和代码块是主要区别,具体原因本卷没有追。

❗IMPORTANT
动脑筋:spec.txt 上,Parser::new_ext 返回时(还没调用 next)已经花掉了完整解析时间的几成?

完整解析指 Parser::new_ext(s, Options::empty()).count(),上面测得 0.444 ms。提示:构造函数里跑的是哪一遍?第一个事件又要等什么?答案在文末。

内存

用一个计数分配器(包住 System,记当前字节数、峰值、分配次数和累计字节)。「峰值」是调用期间存活堆内存的最大值,减去调用前的基线;语料一栏是逐篇解析、每篇的结果用完就丢,所以峰值是单篇里最大的那次:

操作spec.txt 峰值分配次数最大单篇(70 KB)峰值语料峰值语料分配次数
pulldown-cmark:迭代全部事件6742707024508835516813508
comrak:parse_document243302611111635792653231726302
markdown-rs:to_mdast1984008080898394227239422724768996
pulldown-cmark:push_html9364149140688043222426274
comrak:markdown_to_html263277212542783262783262768020
markdown-rs:to_html_with_options1984008087231394227239422724695403

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 + &quot; 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-cmarkcomrak 0.59markdown-rs 1.0commonmark.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、比较事件序列,这类边角会被它持续撞到。

📝NOTE
冷知识:第一版「两遍」是把整份文档的事件全攒进一个 Vec

两遍不是后来的优化,第一个提交之后第八天就有了。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-cmarkcomrakmarkdown-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 KB1.234 ms,2.4 MB35.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 在一个配不上的反引号串之后漏认代码段。

💬 关联讨论

发起讨论
加载中...