Show HN:Samemark 发布,兼容 remark-parse 的 Markdown 解析器,速度提升 10 倍
Show HN: Samemark, a remark-parse compatible Markdown parser that is 10x faster
Samemark 是一个与 remark-parse 兼容的 Markdown 解析器,返回与 mdast-util-from-markdown 相同的 mdast 树(含 GFM 和位置信息),无法精确复现时回退到真实解析器。
与 remark-parse 相同的 mdast,但快得多。
在线演示:一段很长的聊天回答分别流式渲染进 react-markdown(左侧)和
samemark/stream(右侧),每次更新的耗时都在你的浏览器中测量,并检查两个 DOM 是否完全一致,
还有一个 playground,在你输入时对比 samemark 的语法树与 mdast-util-from-markdown。
上面的录屏就是在线演示,录制于 GitHub Actions runner 上的无头 Chromium(录制运行 38066505899:250 次更新,左侧更新耗时 1.76 s,右侧 94 ms,全部 25 次检查中 HTML 完全一致)。在你自己的设备上数字会不同;它们是在你的浏览器中测量的。
samemark 是一个 markdown 解析器,它返回与 mdast-util-from-markdown 相同的 mdast 树,包含 GFM(即
remark-parse + remark-gfm 所产生的结果),并包含位置信息,对于任何它无法精确复现的内容,会回退到那个真正的解析器。
micromark 由 Titus Wormer 定义其行为;这个解析器与它保持一致, 并在必要时回退到它。它的测试套件和输出就是规范。
它是如何做出来的。大部分代码由 AI 智能体编写。每一处改动都会对照 micromark 自身的输出进行检查: 在数千份真实文档、CommonMark 规范示例以及数千万个生成的输入上,整棵树必须逐节点完全相等。这里没有任何东西 依赖于对作者或模型的信任;检查就在这个仓库里,运行它们的命令见下文。
你能得到什么
在真实的 unified 流水线中(用 remark-gfm、remark-rehype、rehype-stringify 把 markdown 转成 HTML),以 samemark 作为解析器时整个运行
快 6.2 倍到 10.3 倍,这是在 GitHub Actions runner(Node 24,7 轮取中位数,每个文件 HTML 完全一致)上对六个输入测得的。仅解析本身就快 28 倍到 47 倍;流水线的其余部分不变,
这就是端到端数字较小的原因。
在 Chromium 中把一段 30,000 字符的聊天回答流式渲染进真实 DOM 时,使用带记忆化块的 samemark/stream 相比 react-markdown 在每个分块时重新渲染整条消息,
主线程耗时少 60 倍,在 CPU 被限制 6 倍以模拟手机时少 77 倍(750 次推送从 79.4 s 降到 1.03 s;每次推送从 103 ms 降到 1.1 ms),且 DOM 相同。仅替换 react-markdown 内部的解析器就能快 5 倍到 6 倍。
构建一个 239 页的 Astro 站点,用 remark-parse 耗时 29.6 s,用 samemark 耗时 22.9 s(减少 23%)。
所有数字都来自同一次 CI 运行(38036885270),可用 npm run bench 复现。细节与方法:基准测试。
parse(source)返回的 mdastRoot与fromMarkdown(source, {extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()]})相等。remarkParseFast可在 unified 处理器中直接替换remark-parse。remark-gfm、remark-frontmatter(yaml、toml 或两者)和remark-math(带或不带singleDollarTextMath) 均已原生实现,可单独或一起使用。其他任何内容(remark-directive、MDX、自定义 frontmatter 围栏、 其他 micromark 扩展)都会被检测出来并交给真正的解析器。你得到的是 micromark 的答案,以 micromark 的 速度。- 解析时不使用 micromark。快速路径只导入六个小型纯辅助包(四个
micromark-util-*包加上ccount和decode-named-character-reference),别无其他。mdast-util-from-markdown仅在回退时需要,作为可选的对等依赖。
安装
npm install samemark
# for the fallback (recommended; the main entry imports it):
npm install mdast-util-from-markdown unified remark-gfmNode 18 或更新版本,或任何支持 ES2020 和 TextDecoder 的运行时(浏览器、Deno、Bun、边缘运行时)。
用法
配合 unified 使用时,替换 remark-parse:
import {unified} from 'unified' import remarkGfm from 'remark-gfm' import remarkRehype from 'remark-rehype' import rehypeStringify from 'rehype-stringify' import remarkParseFast from 'samemark' const file = await unified() .use(remarkParseFast) // instead of remark-parse .use(remarkGfm) .use(remarkRehype) .use(rehypeStringify) .process('# Hello *world*')
在已经安装了 remark-parse 的框架中(Astro、MDX、Next.js 插件),把它加入 remarkPlugins;
它会在 remark-gfm 注册之后替换解析器,这正是它需要的顺序:
// astro.config.mjs import remarkParseFast from 'samemark' export default {markdown: {remarkPlugins: [remarkParseFast]}}
配合 react-markdown 使用时(它在每次渲染时都会构建一个 unified 处理器,且 remark-parse 排在最前,因此设置解析器的插件会接管;已由下面的基准测试验证):
import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkParseFast from 'samemark' const plugins = [remarkParseFast, remarkGfm] // define once, outside the component export const Answer = ({text}) => <Markdown remarkPlugins={plugins}>{text}</Markdown>
聊天应用在每次流式传输 token 时都会重新渲染整条消息,因此解析器每次都要在一个不断增长的字符串上重新运行;参见 Benchmarks 中的 react-markdown 数据。
没有处理器时:
import {parse} from 'samemark' const tree = parse('# Hello *world*') // mdast Root
条目
| 条目 | 回退 | 导入 |
|---|---|---|
samemark |
真正的解析器,mdast-util-from-markdown(可选 peer) |
需要安装 mdast-util-from-markdown |
samemark/stream |
无;用于流式文本的增量解析器,标准 GFM,可选 frontmatter 和 math | 六个辅助包;也支持 CommonJS |
samemark/core |
无:遇到不支持的扩展会抛出错误,除非在插件之前已设置 remark-parse(此时由它接管)或传入了 {fallback} |
仅六个辅助包;也支持从 CommonJS 使用 require('samemark/core') |
主入口还会对每个处理器运行一次性检查:用处理器自身的扩展,由两个解析器分别解析三个小文档;任何差异都会将该处理器送入回退(原因:"self-check ... failed")。这可以防范某个改变行为的 micromark 版本。它是一道绊线,而非证明。
选项
parse(source, options) 和 remarkParseFast(options) 都接受:
| 选项 | 含义 |
|---|---|
strict |
抛出错误而不是回退(当快速解析器自身抛出错误时也是如此)。默认 false。 |
onFallback(reason) |
对每个由回退解析的文档,以原因字符串调用。 |
warn |
每个不同的回退原因对应一个 console.warn。 |
fallback(text, ctx) |
用来替代默认回退的解析器。ctx 有 extensions、mdastExtensions、settings。 |
parse 还额外接受 extensions 和 mdastExtensions(即你会传给 fromMarkdown 的数组)。省略时,假定为标准 GFM。source 可以是字符串、Uint8Array(UTF-8)或带有 toString() 的对象。
没有人应该在性能分析器里才发现回退。stats 由两个入口导出:
import {stats, check} from 'samemark' stats // {fast: 120, fallback: 0, lastFallbackReason: null} check(micromarkExtensions, fromMarkdownExtensions) // null when the fast path is exact, else the reason
面向聊天 UI 的流式处理
聊天 UI 在每次 token 到达时都会重新解析整条消息。以 samemark 作为解析器时,这种重新解析根据运行时的不同便宜 3 到 7 倍,且输出完全相同(react-markdown 数据见 Benchmarks)。samemark/stream 消除了重新解析:它是增量的,并且与解析整段文本完全一致。
import {createStreamParser} from 'samemark/stream' const stream = createStreamParser() // options: {frontmatter, math}, same meaning as remark-frontmatter / remark-math for await (const chunk of tokens) { const tree = stream.push(chunk) // mdast Root, equal to parse(everything pushed so far), positions included render(tree) } stream.end() // final tree; stream.reset() starts a new message
- 每次 push 之后,树都等于
parse()对目前所有文本返回的结果,也等于 micromark 的树。分块可以拆分任何内容:一个 token、一个围栏、一个表格行、一个链接。 - 不再可能改变的块在每次 push 之间保持其对象标识(未完成的尾部会重新解析,其余部分被复用),因此以节点为键的
React.memo会跳过它们。一次 push 的成本是新文本加上未完成的尾部,而不是整条消息。 - 后面的引用或脚注定义可能会改变前面块的解析方式;受影响的块会作为新对象重建,因此标识恰好在内容可能已改变时发生变化。
- 支持标准 GFM,可选 frontmatter 和 math。对于其他扩展,请使用主入口解析整条消息。也支持从 CommonJS 使用
require('samemark/stream')。
React 集成位于 examples/react-chat/StreamMarkdown.jsx:一个 useMarkdownStream() hook 和一个 <StreamMarkdown tree> 组件,用 mdast-util-to-hast 和 hast-util-to-jsx-runtime 渲染 mdast,每个顶层节点对应一个记忆化块,应用与 react-markdown 相同的 URL 清理和原始 HTML 处理。对于整条消息,其 HTML 与 react-markdown 相同(test/stream.test.mjs,以及下方 Chromium 基准测试中的 DOM 比较)。
import {StreamMarkdown, useMarkdownStream} from './StreamMarkdown.jsx' function Answer({tokens}) { // tokens: an async iterable of strings const {tree, push, reset} = useMarkdownStream() useEffect(() => { reset() ;(async () => { for await (const chunk of tokens) push(chunk) })() }, [tokens]) return <StreamMarkdown tree={tree} /> }
流式成本
仅解析器,来自 CI 中的 node bench/stream-bench.mjs(ubuntu-latest,每次 push 12 个字符,生成的 LLM 风格回答):推送整个回答的总时间,每次 push 完整重新解析对比 createStreamParser。
| 回答长度 | 每次 push 完整重新解析 | samemark/stream |
加速比 |
|---|---|---|---|
| 10,451 个字符 | 422 ms | 66 ms | 6.4x |
| 30,416 个字符 | 981 ms | 54 ms | 18.2x |
| 100,193 个字符 | 9,232 ms | 133 ms | 69.4x |
该解析器在 CI 中的身份:465,378 个文档上共 48,196,453 次推送(语料重放、模糊字符串、1 字符、随机、类 token 和整行分块,带或不带 frontmatter 和数学公式),每棵树都与完整解析结果进行比较;0 失败。
包含 React 渲染(DOM 提交、记忆化块):
来自 CI 运行 38036885270(Chromium,GitHub Actions runner);在本地用 npm run bench:stream(jsdom,数字更小)复现,或运行 bench 工作流。
一条聊天消息以每次推送 40 个字符的速度流式写入一个真实 DOM 节点(Chromium 156.0.8078.4,React 19 生产版,每次推送 flushSync,因此每次推送都是解析 + React 渲染 + DOM 提交;不包括布局和绘制)。(a) react-markdown 每次推送重新渲染整条消息,(b) 相同但使用 samemark 作为解析器,(c) 带逐块记忆化的流式解析器(examples/react-chat)。“Total”是主线程上所有推送时间的总和。最终推送后以及每第 25 次推送后的 DOM 与 (a) 进行比较:最后一列。
| CPU | 变体 | 推送次数 | 总计 | 每次推送 p50 | p95 | 最差 | 与 (a) 相同的 DOM |
|---|---|---|---|---|---|---|---|
| 无节流 | (a) react-markdown,每次推送整条消息 | 750 | 11.29 s | 15 ms | 28 ms | 32 ms | 31/31 |
| 无节流 | (b) react-markdown + samemark | 750 | 2.24 s(少 5.0 倍) | 2.9 ms | 5.0 ms | 31 ms | 31/31 |
| 无节流 | (c) 流式解析器 + 记忆化块 | 750 | 187 ms(少 60.3 倍) | 0.2 ms | 0.6 ms | 6.4 ms | 31/31 |
| 慢 4 倍 | (a) react-markdown,每次推送整条消息 | 750 | 50.91 s | 67 ms | 126 ms | 141 ms | 31/31 |
| 慢 4 倍 | (b) react-markdown + samemark | 750 | 9.85 s(少 5.2 倍) | 13 ms | 24 ms | 122 ms | 31/31 |
| 慢 4 倍 | (c) 流式解析器 + 记忆化块 | 750 | 669 ms(少 76.1 倍) | 0.8 ms | 2.2 ms | 30 ms | 31/31 |
| 慢 6 倍 | (a) react-markdown,每次推送整条消息 | 750 | 79.43 s | 103 ms | 198 ms | 219 ms | 31/31 |
| 慢 6 倍 | (b) react-markdown + samemark | 750 | 15.33 s(少 5.2 倍) | 20 ms | 36 ms | 207 ms | 31/31 |
| 慢 6 倍 | (c) 流式解析器 + 记忆化块 | 750 | 1.03 s(少 77.0 倍) | 1.1 ms | 3.2 ms | 45 ms | 31/31 |
环境:Node v24.21.0,GitHub Actions ubuntu24 X64(github 托管),INTEL(R) XEON(R) PLATINUM 8573C x4,linux x64。每个单元格是 1 轮预热后 7 轮的中位数,每个单元格使用一个全新进程。
(a) 是当今聊天 UI 的做法:react-markdown 在每次推送时重新解析并重新渲染整条消息。(b) 仅替换解析器。(c) 是 examples/react-chat。
示例
每个片段都针对此仓库运行;unified、frontmatter 和 fallback 示例打印所示内容。
unified 管道(markdown 转 HTML):
import {unified} from 'unified' import remarkGfm from 'remark-gfm' import remarkRehype from 'remark-rehype' import rehypeStringify from 'rehype-stringify' import remarkParseFast from 'samemark' const file = await unified() .use(remarkParseFast) .use(remarkGfm) .use(remarkRehype) .use(rehypeStringify) .process('# Hello *world*\n\n| a | b |\n|---|---|\n| ~~x~~ | y |') console.log(String(file)) // <h1>Hello <em>world</em></h1> // <table> ... <td><del>x</del></td> ... </table>
react-markdown,改一行:
import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkParseFast from 'samemark' const plugins = [remarkParseFast, remarkGfm] // module level: not recreated per render export const Answer = ({text}) => <Markdown remarkPlugins={plugins}>{text}</Markdown>
使用 samemark/stream 的流式聊天。 在 React 中使用 examples/react-chat(实时演示运行它)。不使用框架时:
import {createStreamParser} from 'samemark/stream' const stream = createStreamParser() let tree for (const chunk of ['# Hel', 'lo\n\nSome **bo', 'ld** text\n\n- a\n- b\n']) { tree = stream.push(chunk) // same tree as parse(everything so far); finished blocks keep their identity } console.log(tree.children.map(node => node.type)) // [ 'heading', 'paragraph', 'list' ]
Astro:
// astro.config.mjs import remarkParseFast from 'samemark' export default {markdown: {remarkPlugins: [remarkParseFast]}}
Frontmatter 和数学公式保持在快速路径上:
import remarkFrontmatter from 'remark-frontmatter' import remarkMath from 'remark-math' import {stats} from 'samemark' const file = await unified() .use(remarkParseFast) .use(remarkGfm) .use(remarkFrontmatter) .use(remarkMath) .use(remarkRehype) .use(rehypeStringify) .process('---\ntitle: Notes\n---\n\nEuler: $e^{i\\pi}+1=0$\n') console.log(String(file)) // <p>Euler: <code class="language-math math-inline">e^{i\pi}+1=0</code></p> console.log(stats) // { fast: 1, fallback: 0, lastFallbackReason: null }
samemark 将数学公式解析为与 remark-math 相同的 inlineMath 和 math 节点;排版是渲染器的工作,与 remark-parse 完全一样。在 remark-rehype 之后添加 rehype-katex(或 rehype-mathjax)以将它们转换为方程式:
import rehypeKatex from 'rehype-katex' unified().use(remarkParseFast).use(remarkGfm).use(remarkMath).use(remarkRehype).use(rehypeKatex).use(rehypeStringify)
浏览器中的 samemark/core(无 fallback 导入;browser 构建约 20 KB gzip 后,见 Bundle size)。它需要一个遵循包 exports 的打包器,例如 esbuild 或 Vite:
import {parse} from 'samemark/core' const tree = parse('# Hello *world*') // mdast Root, GFM
检查 fallback,以免有人在 profiler 中发现一个:
import remarkDirective from 'remark-directive' import remarkParseFast, {stats} from 'samemark' const reasons = [] await unified() .use(remarkParseFast, {onFallback: reason => reasons.push(reason)}) .use(remarkGfm) .use(remarkDirective) // not in the supported set: the real parser takes over .use(remarkRehype) .use(rehypeStringify) .process(':::note\nhi\n:::') console.log(reasons) // ['micromark extension not in the supported set (GFM, frontmatter, math): ...'] console.log(stats) // { fast: 0, fallback: 1, lastFallbackReason: '...' }
传入 {strict: true} 以抛出错误而不是回退。
兼容性
确切声明。快速路径仅针对这些固定版本进行验证(package.json devDependencies,由 npm ci 安装):
| 包 | 版本 |
|---|---|
| micromark | 4.0.3 |
| micromark-extension-gfm | 3.0.0 |
| mdast-util-gfm | 3.1.0 |
| mdast-util-from-markdown | 2.1.0 |
| remark-parse | 11.0.0 |
| remark-gfm | 4.0.1 |
| unified | 11.0.5 |
其他版本不在声明范围内。若新版 micromark 改变了输出,主入口的自检会捕获到(随后使用回退方案),本仓库中的 npm test 也会捕获到——如果标准 GFM 构造集发生变化,该检查就会失败(node scripts/gen-signature.mjs --check)。
是否算作受支持由构造名称决定,而不是由函数源码决定,因此 micromark 的压缩打包版本仍能走快速路径(已用包含 frontmatter 和 math 扩展的 esbuild --minify 打包版本测试过)。隐藏在闭包中的设置(singleTilde: false、singleDollarTextMath: false,即 frontmatter matter 所使用的围栏,anywhere: true)通过在探测字符串上运行真正的分词器来读取;结果会与标准配置的行为进行比较。任何未知构造、任何 disable 条目、自定义或 anywhere frontmatter matter,或缺少 GFM 组件,都意味着回退。
哪些配置会走快速路径
由 node scripts/compat-table.mjs 从 npm test 所断言的相同用例生成(每个快速行还会在一个能触发它的文档上与 remark-parse 进行比较):
| 配置 | 路径 | 报告的原因 |
|---|---|---|
| remark-gfm | 快速 | |
| remark-gfm 两次 | 快速 | |
remark-gfm 搭配 tablePipeAlign / tableCellPadding(仅序列化选项) |
快速 | |
| remark-gfm + remark-breaks(仅树变换) | 快速 | |
| remark-gfm + remark-smartypants(仅树变换) | 快速 | |
remark-gfm 搭配 singleTilde: false |
回退(真实解析器) | strikethrough is not the stock GFM one (singleTilde: false or unknown tokenizer) |
| remark-gfm + remark-frontmatter(yaml) | 快速 | |
remark-gfm + remark-frontmatter ['yaml', 'toml'] |
快速 | |
remark-gfm + remark-frontmatter 'toml' |
快速 | |
remark-gfm + remark-frontmatter 搭配自定义 fence(相同类型,其他围栏) |
回退(真实解析器) | frontmatter fences differ from the stock yaml matters (custom or 任意位置 matter) |
| remark-gfm + remark-frontmatter 搭配自定义 matter 类型 | 回退(真实解析器) | micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 60 ('<') |
remark-gfm + remark-frontmatter 搭配 anywhere: true |
回退(真实解析器) | frontmatter fences differ from the stock yaml matters (custom or 任意位置 matter) |
| remark-gfm + remark-math | 快速 | |
remark-gfm + remark-math 搭配 singleDollarTextMath: false |
快速 | |
| remark-gfm + remark-frontmatter + remark-math | 快速 | |
| remark-frontmatter + remark-math 不带 remark-gfm | 回退(真实解析器) | GFM is not fully enabled (missing micromark construct attentionMarkers|126) |
| remark-gfm + remark-directive | 回退(真实解析器) | micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 58 (':') |
| remark-gfm + remark-frontmatter + remark-directive | 回退(真实解析器) | micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 45 ('-') |
| remark-mdx(MDX) | 回退(真实解析器) | GFM is not fully enabled (missing micromark construct attentionMarkers|126) |
| remark-gfm + remark-mdx | 回退(真实解析器) | micromark extension not in the supported set (GFM, frontmatter, math): disable|{"null":["autolink","codeIndented","htmlFlow","htmlText"]} |
| 不带 remark-gfm(仅 CommonMark) | 回退(真实解析器) | no GFM extensions (CommonMark-only parsing needs the fallback) |
| 未知 micromark 构造 | 回退(真实解析器) | micromark extension not in the supported set (GFM, frontmatter, math): text|atSign |
micromark disable 条目 |
回退(真实解析器) | micromark extension not in the supported set (GFM, frontmatter, math): disable|{"null":["codeIndented"]} |
框架。Astro(纯 markdown,remark-gfm 默认):快速。Starlight 为旁注添加了 remark-directive,因此会回退,得不到任何好处。Docusaurus 和 MDX 通过 micromark-extension-mdxjs 编译:回退。@mdx-js/mdx 搭配 format: 'md' 和 remark-gfm:快速。
如何验证一致性
一眼看正确性(每一行都将完整树,包括位置,与 mdast-util-from-markdown + GFM 进行比较;细节和命令见下表):
| 检查 | 规模 | 结果 | 复现 |
|---|---|---|---|
| CommonMark 和 GFM 规范示例 | 652 个 CommonMark 0.31.2、672 个 GFM 规范及 30 个 GFM 扩展示例 | 全部一致 | npm run check |
| 固定文档语料库和合成语料库 | Node.js API 文档和变更日志、Rust 书、Vite 文档、React 变更日志、240 个生成的文档 | 全部一致 | npm run corpus && npm run check |
| README 语料库 | 4,780 个 npm 包 README | 上次运行时全部一致 | node bench/readme-corpus.mjs && node bench/run.mjs --readme --nospeed |
| 留出文件 | 10,893 个非 README markdown 文件 | 上次运行时全部一致 | node bench/heldout.mjs --roots=DIR |
| 差分模糊测试 | 数千万个生成的输入(每周完整矩阵约 6000 万) | 0 失败;已知的边角情况列在 KNOWN_GAPS.md 中 | node bench/fuzz.mjs --mode=regress、ci.yml、full.yml |
| 流式解析器,每次推送后的树 | 465,378 个文档上共 48,196,453 次推送 | 0 失败 | node bench/stream-diff.mjs --mode=regress,full.yml |
“完全相同”指 JSON 相等的树,包括每个 position,而非“看起来一样”(firstDiff 在
bench/common.mjs 中;undefined 字段视为不存在)。
| 检查 | 内容 | 命令 |
|---|---|---|
| 规范 | 全部 652 个 CommonMark 0.31.2 示例、672 个 GFM 规范示例和 30 个 GFM 扩展示例,完整树比较 | npm run check |
| 文档与合成语料库 | Node.js API 文档和更新日志、Rust 书、Vite 文档、一份 React 更新日志(固定提交,在 CI 中获取,bench/corpora.json)以及 240 份生成文档(bench/synthetic.mjs),完整树比较 |
npm run corpus && npm run check |
| README 语料库(开发用) | 作者机器上收集的 4,780 个 npm 包的 README.md 文件(30 MB),完整树比较(上次运行时全部相同)。其中 4,648 个位于 bench/readme-corpus-manifest.json(包、版本、sha256),可从 npm 重建,但速度较慢 |
node bench/readme-corpus.mjs && node bench/run.mjs --readme --nospeed |
| CommonMark 规范 | 规范 0.31.2 的全部 652 个示例 | npm run check |
| 留出集 | 作者机器上的 10,893 个非 README markdown 文件,编写解析器时从未使用(上次运行时全部相同)。文件列表因机器而异,因此他人无法重复这一确切集合;请传入你自己的列表 | node bench/heldout.mjs --roots=DIR |
| 差分模糊测试 | 穷举字符串(17 个字符,长度最多 6)、随机、令牌偏置、语料库段落的变异、规范交叉 | node bench/fuzz.mjs --mode=exhaustive --maxlen=5 --shard=0/64(完整矩阵约 6000 万个用例,分布在 60 个任务中,为 .github/workflows/full.yml,每周或手动运行;ci.yml 在每次推送时运行约 200 万个用例) |
| Frontmatter 与数学 | 在语料库和规范示例前添加 yaml / toml / 自定义 frontmatter(CRLF、未闭合围栏、不在开头)并启用数学,加上扩展感知的模糊测试 | node bench/frontmatter.mjs --sample=24 |
| 流式处理 | samemark/stream 以随机块大小重放文档;每次推送后的树必须等于完整解析,以及 micromark 在检查点处的结果(语料库、规范示例、边缘输入、延迟到达的定义) |
node bench/stream-diff.mjs --mode=regress,node --test test/stream.test.mjs |
| 回归 | 所有曾经失败的输入 | node bench/fuzz.mjs --mode=regress |
| 边缘输入 | CRLF、CR、BOM、NUL、孤立代理项、空、5 万字符行、深层嵌套、unicode、制表符;CI 中的 10 MB 输入 | node --test test/edge.test.mjs;10 MB 时为 BIG=1 |
| 打包 | 压缩包保留快速路径、边缘运行时 VM、Chromium、Deno、Node 18 到 24、空项目中的打包 tarball | npm test;ci.yml(Node 18 到 24,打包 tarball),full.yml(Deno、Chromium、10 MB 输入) |
第三方文本从不提交。文档语料库在固定提交处获取(许可证:Node.js、Vite 和 React 为 MIT,Rust 书为 MIT 或 Apache-2.0,GFM 规范示例为 BSD-2-Clause 代码及 CC-BY-SA 规范文本), README 语料库通过 sha256 清单从 npm 重建,合成语料库由种子生成。
已知差异
在测试集(规范示例、语料库、留出集、边缘输入)上没有差异。差分模糊测试确实发现了 存在差异的形态,都是围绕脚注定义和列表的块级角落;它们连同最小 输入列在 KNOWN_GAPS.md 中,目前尚未出现在真实文档中。身份声明未覆盖的内容: 非 GFM 扩展(按设计回退)、非字符串输入类型,以及兼容性表中未列出的其他流水线。
故意表现不同的行为:micromark 在大约 10,000 层嵌套时会抛出 RangeError;samemark 则能解析它。大型单一输入:micromark 的耗时随文档大小增长快于线性(在快速笔记本电脑上,一个混合文档在 400 KB 时为 1.6 秒,800 KB 时为 5.1 秒,1.6 MB 时为 19 秒;一个 500 KB 的内联标记段落需要 16 秒;一次 10 MB 的对比运行了 42 分钟并耗尽了 6 GB 堆内存),而 samemark 解析一个 10 MB 的混合文档大约只需 0.4 秒。这就是为什么边缘测试中的全树对比止步于 2 到 3 MB,而 10 MB 的用例只检查 samemark 能完成且不抛出异常。
基准测试
哪些数字来自哪个输入。 下表由 bench 工作流(以及解析器级别的流式基准测试由 bench/stream-bench.mjs)在该项目还位于私有仓库时生成。名为“README 语料库”的行运行在包含 4,780 个文件的 npm 包 README 开发语料库上(较慢的测试使用 1/4 或 1/16 的抽样;其中 4,648 个文件可从 bench/readme-corpus-manifest.json 重建)。以仓库命名的行(node-api-docs、rust-book、vite-docs、node-changelog-v22、react-changelog)运行在 bench/corpora.json 中固定的提交上;任何人都可以用 npm run corpus 精确获取这些提交。react-markdown 表格使用生成的聊天风格回答(bench/perf/chat-text.mjs),Astro 站点使用三个文档集,而在全新检出上运行 npm run bench 会测量固定的文档集加上合成语料库(以及重建后的 README 语料库,使用 --readme)。运行器 CPU 因任务和运行而异,因此请比较同一表格内的比率,而不是跨表格的绝对时间。
环境:Node v24.21.0,GitHub Actions ubuntu24 X64(github 托管),AMD EPYC 7763 64 核处理器 x4,linux x64。每个单元格是 7 轮(在一轮预热之后)的中位数,每个单元格使用一个全新进程。
端到端:使用 unified 将 markdown 转换为 HTML
unified().use(parser).use(remarkGfm).use(remarkRehype).use(rehypeStringify),使用 remark-parse 或使用 samemark 作为解析器。输出 HTML 逐文件比较。
| 输入 | 文件 | 大小 | remark-parse | samemark | 加速比 | 解析占比(之前) | 解析占比(之后) | HTML 相同 |
|---|---|---|---|---|---|---|---|---|
| node-api-docs | 70 | 4.75 MB | 5.00 s | 486 ms | 10.3x | 92% | 33% | 70/70 |
| node-changelog-v22 | 1 | 0.88 MB | 1.33 s | 171 ms | 7.8x | 89% | 21% | 1/1 |
| rust-book | 112 | 1.22 MB | 1.07 s | 125 ms | 8.6x | 93% | 40% | 112/112 |
| vite-docs | 57 | 0.56 MB | 570 ms | 73 ms | 7.8x | 92% | 29% | 57/57 |
| react-changelog | 1 | 0.30 MB | 498 ms | 71 ms | 7.0x | 90% | 19% | 1/1 |
| 合成语料库(240 个生成的文档) | 66 | 0.25 MB | 546 ms | 89 ms | 6.2x | 88% | 22% | 66/66 |
内容流水线,如文档或博客站点运行的方式:remark-gfm、remark-frontmatter(yaml)以及 remark-rehype 之前的 remark-math。vite-docs 的每个页面都有真实 frontmatter;合成(或 README)文档会获得一个生成的块,因此每个文件都有一个。两侧在 HTML 上进行比较。
| 输入 | 文件 | 大小 | remark-parse | samemark | 加速比 | HTML 相同 | samemark 路径 |
|---|---|---|---|---|---|---|---|
| 合成语料库(240 个生成的文档)添加了一个 frontmatter 块 | 66 | 0.26 MB | 559 ms | 95 ms | 5.9x | 66/66 | fast |
| vite-docs | 57 | 0.56 MB | 543 ms | 66 ms | 8.2x | 57/57 | fast |
| react-changelog | 1 | 0.30 MB | 517 ms | 70 ms | 7.3x | 1/1 | fast |
作为参考,同一输入上的其他转换器(不同的流水线和输出:HTML 字符串,无 mdast,不是直接替代品):
| 输入 | markdown-it render(HTML) |
marked parse(HTML) |
|---|---|---|
| node-api-docs | 477 ms | 449 ms |
| node-changelog-v22 | 182 ms | 144 ms |
| rust-book | 122 ms | 103 ms |
| vite-docs | 74 ms | 66 ms |
| react-changelog | 71 ms | 57 ms |
| 合成语料库(240 个生成的文档) | 89 ms | 70 ms |
环境:Node v24.21.0,GitHub Actions ubuntu24 X64(github 托管),AMD EPYC 7763 64 核处理器 x4,linux x64。每个单元格是 7 轮(在一轮预热之后)的中位数,每个单元格使用一个全新进程。
react-markdown(桌面端,Node,renderToStaticMarkup,React 生产构建)
react-markdown 10.1.0 在每次渲染时都会构建一个新的统一处理器(unified().use(remarkParse).use(remarkPlugins)...),因此 remarkPlugins={[remarkParseFast, remarkGfm]} 在每次渲染时都会替换其解析器。输入是一个合成的聊天风格回答(标题、列表、代码、表格、链接;bench/perf/chat-text.mjs)。输出 HTML 在 120 个前缀长度上进行比较,包括不完整的结构:120/120 完全相同。samemark 路径:6148 次快速,0 次回退。
在固定消息长度下的一次更新(这也是渲染一条已完成长回答的成本):
| 消息长度 | react-markdown 默认 | 使用 samemark | 加速比 |
|---|---|---|---|
| 10,000 个字符 | 36 ms | 5 ms | 6.5x |
| 30,000 个字符 | 92 ms | 13 ms | 7.0x |
流式模拟:消息每次增长几个字符,并且每一步之后都会重新渲染整条消息,正如聊天应用所做的那样:
| 流式 | 更新次数 | 默认:总时间 / 平均 / 最差更新 | samemark:总时间 / 平均 / 最差更新 | 加速比 |
|---|---|---|---|---|
| 到 10,000 个字符,每步 20 个 | 500 | 3.25 s / 6 ms / 16 ms | 676 ms / 1 ms / 4 ms | 4.8x |
| 到 30,000 个字符,每步 20 个 | 1500 | 27.98 s / 19 ms / 47 ms | 5.31 s / 4 ms / 9 ms | 5.3x |
Chromium 中的 react-markdown,CPU 降速作为手机代理
Chromium 156.0.8078.4,react-markdown 10.1.0 生产包,页面中的 renderToStaticMarkup。CPU 降速使用 DevTools Emulation.setCPUThrottlingRate(主线程 4 倍和 6 倍降速)。这是对较慢设备的代理,而不是对某台设备的测量。与桌面表格相同的聊天风格输入;这里的流式步骤为 100 个字符,以保持降速运行时间较短。
| CPU | 10,000 个字符时的更新:默认 / samemark | 30,000 个字符时的更新:默认 / samemark | 流式到 10,000:平均更新 默认 / samemark | 流式到 30,000:平均更新 默认 / samemark | 相同的 HTML |
|---|---|---|---|---|---|
| 无降速 | 9.6 ms / 2.0 ms(4.8x) | 29 ms / 5.4 ms(5.4x) | 5.1 ms / 1.0 ms(4.9x) | 14 ms / 2.7 ms(5.3x) | 60/60 |
| 4 倍慢 | 45 ms / 7.9 ms(5.7x) | 145 ms / 22 ms(6.6x) | 24 ms / 4.3 ms(5.5x) | 65 ms / 12 ms(5.5x) | 60/60 |
| 6 倍慢 | 70 ms / 12 ms(6.0x) | 200 ms / 35 ms(5.8x) | 35 ms / 6.5 ms(5.3x) | 102 ms / 18 ms(5.8x) | 60/60 |
将一条 30,000 个字符的回答流式写入 DOM
一条聊天消息以每次推送 40 个字符流式写入真实 DOM 节点(Chromium 156.0.8078.4,React 19 生产环境,每次推送 flushSync,因此每次推送都是解析 + React 渲染 + DOM 提交;不包括布局和绘制)。(a) react-markdown 每次推送重新渲染整条消息,(b) 使用 samemark 作为解析器的相同情况,(c) 带有逐块记忆化的流式解析器(examples/react-chat)。“总计”是主线程上所有推送时间的总和。最终推送后以及每第 25 次推送后的 DOM 与 (a) 进行比较:即最后一列。
| CPU | 变体 | 推送次数 | 总计 | 每次推送 p50 | p95 | 最差 | 与 (a) 相同的 DOM |
|---|---|---|---|---|---|---|---|
| 无降速 | (a) react-markdown,每次推送整条消息 | 750 | 11.29 s | 15 ms | 28 ms | 32 ms | 31/31 |
| 无降速 | (b) react-markdown + samemark | 750 | 2.24 s(少 5.0x) | 2.9 ms | 5.0 ms | 31 ms | 31/31 |
| 无降速 | (c) 流式解析器 + 记忆化块 | 750 | 187 ms(少 60.3x) | 0.2 ms | 0.6 ms | 6.4 ms | 31/31 |
| 4 倍慢 | (a) react-markdown,每次推送整条消息 | 750 | 50.91 s | 67 ms | 126 ms | 141 ms | 31/31 |
| 4 倍慢 | (b) react-markdown + samemark | 750 | 9.85 s(少 5.2x) | 13 ms | 24 ms | 122 ms | 31/31 |
| 4 倍慢 | (c) 流式解析器 + 记忆化块 | 750 | 669 ms(少 76.1x) | 0.8 ms | 2.2 ms | 30 ms | 31/31 |
| 6 倍慢 | (a) react-markdown,每次推送整条消息 | 750 | 79.43 s | 103 ms | 198 ms | 219 ms | 31/31 |
| 6 倍慢 | (b) react-markdown + samemark | 750 | 15.33 s(少 5.2x) | 20 ms | 36 ms | 207 ms | 31/31 |
| 6 倍慢 | (c) 流式解析器 + 记忆化块 | 750 | 1.03 s(快 77.0 倍) | 1.1 ms | 3.2 ms | 45 ms | 31/31 |
环境:Node v24.21.0,GitHub Actions ubuntu24 X64(github-hosted),INTEL(R) XEON(R) PLATINUM 8573C x4,linux x64。每个单元格为 1 轮预热后 7 轮的中位数,每个单元格使用一个全新进程。
仅解析
两侧使用相同的 mdast(mdast-util-from-markdown 使用 micromark-extension-gfm 和 mdast-util-gfm,即参考实现)。"Identical" 统计的是完整树(包括位置信息)相等的文件。
| 输入 | 文件 | 大小 | mdast-util-from-markdown + GFM | samemark | 加速比 | 完全相同 |
|---|---|---|---|---|---|---|
| node-api-docs | 70 | 4.75 MB | 4.34 s | 121 ms | 35.7x | 70/70 |
| node-changelog-v22 | 1 | 0.88 MB | 1.06 s | 23 ms | 46.8x | 1/1 |
| rust-book | 112 | 1.22 MB | 921 ms | 32 ms | 29.1x | 112/112 |
| vite-docs | 57 | 0.56 MB | 486 ms | 17 ms | 27.9x | 57/57 |
| react-changelog | 1 | 0.30 MB | 414 ms | 13 ms | 31.7x | 1/1 |
| 合成语料库(240 个生成的文档) | 240 | 1.08 MB | 1.54 s | 51 ms | 30.3x | 240/240 |
同一输入上的其他解析器。输出不同:markdown-it 返回 token 列表,marked 返回自己的 token 树,两者都不是 mdast,也都不是可直接替换的替代品。此处仅用于展示规模。
| 输入 | markdown-it parse(tokens) |
marked lexer(tokens) |
|---|---|---|
| node-api-docs | 423 ms | 298 ms |
| node-changelog-v22 | 154 ms | 81 ms |
| rust-book | 94 ms | 63 ms |
| vite-docs | 64 ms | 34 ms |
| react-changelog | 61 ms | 30 ms |
| 合成语料库(240 个生成的文档) | 210 ms | 139 ms |
环境:Node v24.21.0,GitHub Actions ubuntu24 X64(github-hosted),AMD EPYC 7763 64-Core Processor x4,linux x64。每个单元格为 1 轮预热后 7 轮的中位数,每个单元格使用一个全新进程。
MDX(@mdx-js/mdx compile)
纯 format: 'md' 搭配 remark-gfm,默认解析器对比以 samemark 作为解析器。format: 'mdx' 会加载 micromark-extension-mdxjs,而它不在标准 GFM 集合中,因此 samemark 按设计在此处回退;它是在有效 MDX 的文件上测量的,以表明开销为零。比较的是编译输出。
| 输入 | 格式 | 编译的文件数 | 默认 | 使用 samemark | 加速比 | 输出完全相同 | samemark 路径 |
|---|---|---|---|---|---|---|---|
| node-api-docs | md | 70 个中的 70 个 | 6.21 s | 1.55 s | 4.0x | 是 | 280 个快速,0 个回退 |
| node-api-docs | mdx | 70 个中的 0 个 | n/a | n/a | n/a | n/a | 此集合中没有文件是有效的 MDX |
| node-changelog-v22 | md | 1 个中的 1 个 | 1.68 s | 509 ms | 3.3x | 是 | 4 个快速,0 个回退 |
| node-changelog-v22 | mdx | 1 个中的 0 个 | n/a | n/a | n/a | n/a | 此集合中没有文件是有效的 MDX |
| rust-book | md | 112 个中的 112 个 | 1.34 s | 367 ms | 3.7x | 是 | 448 个快速,0 个回退 |
| rust-book | mdx | 112 个中的 29 个 | 245 ms | 251 ms | 1.0x | 是 | 0 个快速,116 个回退 |
真实站点:Astro 6.4.8 构建 239 个真实 markdown 页面
rust-book、vite-docs、node-api-docs(6.53 MB 的 markdown),标准 Astro markdown 流水线(remark-parse、remark-gfm、smartypants、Shiki、rehype-raw)。每次均为冷构建(移除 dist、.astro 和 Vite 缓存),每个变体交替构建 3 次,显示中位数,Node v24.21.0,AMD EPYC 7763 64-Core Processor x4。
| 解析器 | 构建墙钟时间 | markdown 解析器内部耗时 | 解析器占构建的比例 | 解析的文档数 | 回退 |
|---|---|---|---|---|---|
| remark-parse(默认) | 29.6 s | 7.07 s | 23.9% | 239 | n/a |
| samemark | 22.9 s | 0.50 s | 2.2% | 239 | 0 |
构建时间节省:6.7 s(22.7%)。解析器时间节省:6.57 s。
各次构建(s):默认 29.2、29.6、29.7;samemark 22.7、22.9、23.3。
解读 react-markdown 的数字。每次更新的加速比小于仅解析的数字,因为 React 渲染和 hast 到 JSX 的步骤没有变化。它因运行时而异:Node 6.5x 到 7.0x,未限速的 Chromium 4.8x 到 5.4x,限速的 Chromium 5.3x 到 6.6x。我没有经过验证的解释来说明为什么该比率在 CPU 限速下会增大(限速会减慢主线程;垃圾回收和编译线程不会以同样的方式被减慢,这只是猜测,不是测量结果)。绝对数字才是实际结果:在 6x 限速下,一个完成的 30,000 字符答案渲染耗时 35 ms 而不是 200 ms。
复现:
npm ci npm run bench:small # a few seconds: the synthetic corpus sample plus one changelog, laptop sized npm run bench # the full run (what CI ran), fetches the pinned documents first
npm run bench 打印出上面的确切表格。方法:每个单元格都是一个全新的 Node 进程(独立的 JIT 和堆),
一轮预热,然后对输入的所有文件取指定轮数的中位数;轮次之间强制 GC;
在报告计时之前,比较双方输出是否完全一致(否则运行失败)。
markdown-it 和 marked 仅用于规模对比:它们生成 token 或 HTML,而非 mdast,且并非可直接替换。
文档:bench/corpora.json 固定了每个集合的仓库、提交和 sha256。
打包体积
使用 esbuild 压缩,ESM,平台中立。node scripts/size.mjs 复现了该表格。
| 打包 | 压缩后 | gzip | brotli |
|---|---|---|---|
| samemark/core(解析器 + 6 个辅助包,快速路径所需的一切) | 87.4 KB | 31.2 KB | 27.0 KB |
面向浏览器的 samemark/core(打包器使用 browser 条件:实体由 DOM 解码) |
52.6 KB | 19.5 KB | 17.4 KB |
| samemark 主入口,mdast-util-from-markdown 保持外部(peer) | 88.4 KB | 31.7 KB | 27.5 KB |
| samemark 主入口,全部打包(包含 mdast-util-from-markdown + micromark) | 144.0 KB | 47.1 KB | 40.9 KB |
| 其中:decode-named-character-reference(HTML 实体表) | 35.0 KB | 11.6 KB | 9.6 KB |
| 基线:mdast-util-from-markdown + micromark-extension-gfm + mdast-util-gfm | 114.6 KB | 34.6 KB | 29.7 KB |
| 基线:unified + remark-parse + remark-gfm | 143.6 KB | 44.1 KB | 37.8 KB |
browser 行是打包器为浏览器生成的产物:decode-named-character-reference 随后通过 DOM 解码实体,而不是附带其 35 KB 的表。边缘运行时(workerd、worker、edge-light、deno)则使用该表。
适用场景
Sätteri,这个作为 Astro 7 默认 markdown 处理器的 Rust 引擎,在其文档中表示它 “并非 unified 的直接替代品”,并且现有的 remark 和 rehype 插件无法未经修改地运行。samemark 面向那些继续使用 unified 的项目:相同的 mdast,现有插件无需改动即可运行, 而且它是纯 JavaScript,没有原生二进制文件。
致谢
- micromark、mdast-util-from-markdown
以及 GFM 扩展由 Titus Wormer 及贡献者定义了这个解析器所复现的行为,并提供了
它导入的辅助包(
micromark-util-*、ccount、decode-named-character-reference)。 - CommonMark 规范及其示例。
- unified 和 remark。
许可证
MIT。dist/core.cjs 中打包代码的第三方许可证见 dist/LICENSES.txt。
来源:Hacker News · github.com
