Skip to content
Published on

マークダウン/MDXパイプライン性能 — satteriとネイティブコアが実際に変えるもの

シェア
Authors

はじめに — ドキュメント3000ページ、ビルド2分22秒

ドキュメントサイトが大きくなると、ある時点からビルドがCIパイプラインで最も長い項目になります。ページが数百のときは気にならなかったことが、数千になるとデプロイのリードタイムを左右するようになり、ローカルで誤字ひとつ直して確認するだけでもコーヒーを淹れに行く時間ができるほどになります。

この問題への最近の答えがsatteriです。GeekNewsでは「JavaScript生態系向けの高性能MarkdownおよびMDXプロセッサ」として紹介され、リポジトリ自身の要約が的確です — Rustでパースとコンパイルを行い、プラグインの実行はJavaScriptで行う。プロジェクトが指摘する問題も明確です。JavaScript生態系には豊富なプラグインがあるもののパーサーが遅く、Rust生態系はその逆だということです。

ただし、ツールを乗り換える前にやるべきことがあります。時間が実際に何に使われているかを測ることです。大きなMDXサイトではMarkdownのパースがボトルネックになっているケースは、思うより少ないものです。この記事はまず段階別のコスト構造とプロファイリングの手順を組み立て、そのうえでsatteriのようなネイティブコアが何を変え、何を諦めるのかを見ていきます。ビルドが遅くなる一般的な理由については、ビルドはなぜこんなに遅いのかの回にまとめました。

時間はどこへ行くのか — 五つの段階

MDXファイル1つがHTMLになるまでに経る段階は次のとおりです。段階ごとにコストの性質が異なるため、ひとまとめに考えても対策は出てきません。

段階やることページ数に対するコスト主な無駄測り方
パースmicromarkがMarkdownをトークン化してmdastを作る線形、文書の長さに比例ほぼなしCPUプロファイルのmicromarkフレーム
変換remark・rehypeのプラグインがツリーを一回ずつ巡回する線形 × プラグイン数使っていないプラグイン、重複した巡回プラグインごとの計測
コンパイルmdastをhastに、さらにJSX/JSに変換する。MDXは式のパースも行う線形MDXの式を含まない文書までMDXとして処理CPUプロファイルのacorn・estreeフレーム
バンドル生成されたJSをバンドラーが再びパース・変換・ツリーシェイキングする線形だが係数が大きいページごとに別モジュール、共通チャンク未分離バンドラー自体のプロファイル
プリレンダリング各ページを実行してHTMLを作る線形だが係数が最も大きいコードハイライターや画像処理の初期化ビルドログの段階別時間

実務で繰り返し確認される事実がひとつあります。ページ数が数千の単位になると、下の二行が上の三行を圧倒することが多いのです。Markdownのパースは1文書あたりミリ秒単位ですが、プリレンダリングは1ページあたり数十ミリ秒かかり、そこにコードハイライティングと画像最適化が加わります。特にShiki系のハイライターは文法とテーマを読み込む初期コストが大きく、設定次第ではページごとに同じ作業をやり直すこともあります。

したがって、Markdownプロセッサの乗り換えが意味を持つのは、パースと変換が実際に上位コストである場合だけです。そうでないサイトでは、パーサーを5倍速くしても全体のビルドは数パーセントしか縮みません。

プロファイリング — 推測ではなく計測する

計測は三つの層で行えば十分です。

第1層: ビルド全体を数回走らせてばらつきを見る。一度測っただけで結論を出すと、たいてい間違えます。

# キャッシュを消して3回繰り返し、平均とばらつきの両方を見る
hyperfine --warmup 1 --runs 3 --prepare 'rm -rf .next .contentlayer' 'pnpm build'

第2層: Node CPUプロファイルでどのコードが時間を使っているかを見る

# V8 CPUプロファイルを出力する(.cpuprofileファイルが生成される)
node --cpu-prof --cpu-prof-dir=./prof ./node_modules/.bin/next build

# Chrome DevToolsのPerformanceパネルで.cpuprofileを開き、Self Time順に並べ替える

プロファイルで見るべきなのは上位フレームの性質です。micromark系が上位にあればパース、unist-util-visitが上位にあればプラグインの巡回、acorn系ならMDXの式コンパイル、ハイライターの名前が見えればそこが犯人です。

第3層: プラグインごとに時間を分けて見る。これが最も実行可能な情報を与えてくれます。unifiedのアタッチャーをラップすれば、プラグインごとの巡回時間を計測できます。

// time-plugins.mjs — プラグインごとのツリー巡回時間を計測する
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'

const totals = new Map()

// アタッチャーをラップし、トランスフォーマーの実行時間だけを積算する
function timed(plugin, name, options) {
  return function (...args) {
    const transformer = plugin.call(this, options)
    if (typeof transformer !== 'function') return transformer
    return async (tree, file) => {
      const t0 = performance.now()
      const result = await transformer(tree, file)
      totals.set(name, (totals.get(name) ?? 0) + (performance.now() - t0))
      return result
    }
  }
}

const processor = unified()
  .use(remarkParse)
  .use(timed(remarkGfm, 'remark-gfm'))
  .use(remarkRehype)
  .use(timed(rehypeSlug, 'rehype-slug'))
  .use(timed(rehypeAutolinkHeadings, 'rehype-autolink-headings'))
  .use(rehypeStringify)

const source = readFileSync(process.argv[2], 'utf8')
const t0 = performance.now()
for (let i = 0; i < 100; i++) await processor.process(source)
const total = performance.now() - t0

console.table(
  [...totals].map(([name, ms]) => ({
    plugin: name,
    ms: +ms.toFixed(1),
    share: `${((ms / total) * 100).toFixed(1)}%`,
  }))
)
console.log(`total ${total.toFixed(1)}ms for 100 passes`)

このスクリプトを実際のリポジトリで一番大きな文書のいくつかに対して走らせると、たいてい意外な結果が出ます。目次生成や見出しアンカーのような、一見些細なプラグインが上位に来ることもあれば、逆にプラグインの合計が無視できる水準で、パイプラインの乗り換え自体に意味がないと分かることもあります。どちらの結果であっても、その情報が次の判断を決めます

プラグインチェーンがコストになる理由

unifiedの設計は優雅です。プロセッサにプラグインを差し込めば、各プラグインがツリーを受け取って直し、次に渡します。問題はこの優雅さのコスト構造にあります。

各プラグインはそれぞれが独自にツリー全体を巡回します。GFMテーブル、タスクリスト、オートリンク、取り消し線、スマート引用符、ディレクティブ、見出しスラッグ、アンカーリンク — これらがひとつずつ巡回を追加していきます。1つの文書にプラグインが12個あれば、ツリーを12回巡ることになります。個々の巡回がやることは少なくても、ノードオブジェクトを辿ってポインタを追いかける作業なのでキャッシュ局所性が悪いのです。

ここにMDXが乗ると、もう一枚レイヤーが重なります。MDX文書のJSXと中括弧の式はJavaScriptパーサーで改めてパースしてestreeにしなければならず、出力結果はHTMLではなく実行可能なJSモジュールになります。Markdownのコンパイルが終わったあとも、バンドラーがそのJSを再びパースします — 同じ内容が実質三回パースされる構造です。

そのため、パイプラインを乗り換えなくても即座に効果が出る対策があります。

  • 使っていないプラグインを取り除く。テンプレートから受け継いだプラグインがそのまま残っているケースは実に多いものです。ひとつずつ外しながら結果のHTMLを比較すればわかります。
  • 複数のプラグインをひとつの巡回にまとめる。見出しをいじるプラグインが3つあるなら、自作の1つにまとめられます。保守コストと引き換えにする選択です。
  • MDXが不要な文書はMarkdownとして処理する。コンポーネントを使わない文書までMDXとしてコンパイルすると、JSパースのコストを無駄に払うことになります。
  • コードハイライティングをビルドから外す。ハイライターをビルド時に毎回走らせる代わりに結果をキャッシュするか、必要な言語とテーマだけを読み込むよう絞り込みます。これひとつでビルドが半分になるサイトも珍しくありません。

satteri — ネイティブコアが変えるものと諦めるもの

satteriの構造はリポジトリに比較的明確に書かれています。Rustのクレートがパイプラインを構成し、napi-rsのバインディングがNodeに公開し、TypeScriptの層がプラグインAPIを提供します。

  • satteri-pulldown-cmark — MDX拡張を付けたpulldown-cmarkのフォーク。CommonMarkのパースを担当します。
  • satteri-mdxjs-rs — Titus Wormerのmdxjs-rsをフォークし、pulldown-cmarkとOXCを使うように変更したMDXコンパイラ。
  • satteri-arena — アリーナアロケータとバイナリバッファのプリミティブ型。
  • satteri-ast — mdast/hastのノード型とコーデック、ツリー操作。
  • satteri-plugin-api — プラグインのトレイトと型付きビジター、実行エンジン。

核心となる設計判断を3つ挙げるとこうなります。第一に、パーサーがpulldown-cmarkベースなので、単一パスのイベントストリームに近い形で動作します。第二に、ASTがアリーナにバイナリ形式で配置されるため、ノード1つごとにオブジェクトを割り当ててポインタを追いかけるコストが消えます。第三に、GFMテーブル・タスクリスト・脚注・取り消し線・数式・見出し属性・YAMLフロントマターといった拡張機能が、プラグインではなくパーサーの機能として組み込まれています。これまでプラグインの巡回で処理していたものが、パース一回に吸収されるということです。

インストールと使用方法は平凡です。napi-rsで事前コンパイルされたネイティブバイナリを配布するため、Rustツールチェーンは不要です。macOS(Apple Silicon・Intel)、Linux x86_64 glibc、Windows x86_64向けのバイナリが提供され、それ以外の環境はWASIビルドにフォールバックします。

npm install satteri
# Vite統合 — .md/.mdxを直接importできる
npm install vite-plugin-satteri
import { markdownToHtml, mdxToJs, defineMdastPlugin } from 'satteri'

// mdastレベルのプラグイン — 型推論のためにdefineヘルパーを使う
const stripDrafts = defineMdastPlugin({
  name: 'strip-drafts',
  visit: {
    blockquote(node, ctx) {
      if (ctx.text(node).startsWith('DRAFT:')) ctx.remove(node)
    },
  },
})

const html = await markdownToHtml(source, { mdastPlugins: [stripDrafts] })
const js = await mdxToJs(source, { mdastPlugins: [stripDrafts] })

ここで必ず区別しておくべきことがあります。satteriは独自のプラグインAPIをmdastとhastの2つのレベルで提供します。しかし、これは既存のremark-* / rehype-*のnpmパッケージがそのまま使えるという意味ではありません。それらのプラグインはunifiedプロセッサと通常のJavaScriptオブジェクトツリーを前提に書かれており、satteriのツリーはアリーナにバイナリで格納されたままnapiの境界を越えてきます。実際、Astroがsatteriを選択可能なプロセッサとして組み込んだ際に告知された制約も、まさにこの点でした — プロセッサをsatteriに変えるとunifiedチェーン全体が置き換わり、remark-tocrehype-slugrehype-autolink-headingsのようなエコシステムのプラグインは動作しません。

以下の設定例は2次情報の記事から引用されたものなので、実際のパッケージ名やオプションはAstro公式ドキュメントで改めて確認してください。

// astro.config.mjs — Astroでのオプトイン形式(報道ベース)
import { satteri } from '@astrojs/markdown-satteri'

export default defineConfig({
  markdown: {
    processor: satteri({
      features: { directive: true },
    }),
  },
})

まとめると、トレードオフはこうなります。

  • 得られるもの: パースとコンパイルがネイティブ速度まで下がり、拡張機能がプラグイン巡回なしに処理されます。プラグインが少なく文書が多いサイトほど恩恵が大きくなります。
  • 失うもの: unifiedエコシステムのプラグイン資産全体です。カスタム処理が必要なら、satteriのプラグインAPIで書き直す必要があります。
  • 確認が必要なもの: JSプラグインをひとつでも付けると、文書ごとにRustとJSの間を行き来することになります。この境界コストがプラグインの数に応じてどう増えるかは、公開されている文書では数値として確認できませんでした。プラグインを多く付ける計画があるなら、自分の文書集合で直接測ってみるほうが安全です。

ベンチマークをどこまで信じるか

この部分は正直に書きます。

よく引用される数値があります。Astroのドキュメントサイトが142秒から63秒(約2.25倍)、Cloudflareのドキュメントが120秒から55秒(約2.18倍)、中規模のマーケティングサイトが38秒から22秒(約1.73倍)。ビルド1回あたり79秒を節約すると、1日50回ビルドする前提で年間およそ230 CI時間になる、という計算も一緒に出回っています。

確認できた範囲では、状況はこうです。

  • これらの数値は、satteriの公式サイトやリポジトリで示されたものではありません。確認した時点でのsatteri公式サイトには、ブラウザWASMデモの秒間文書処理数の表示があるだけで、unified・remark・mdx-jsとの比較数値や再現可能なベンチマークハーネスは公開されていませんでした。
  • 上の表は、Astro統合を扱った2次メディアの記事で引用される形で出回っています。計測に使ったハードウェア、キャッシュの状態、プラグイン構成、実行回数といった再現条件は示されていません。
  • したがって、「2倍程度速くなる」という方向性自体はアーキテクチャ上もっともらしいものの、上の数字を自分のプロジェクトの期待値にするのは根拠が不十分です

ベンチマークを読むときに確認すべき項目をまとめると、こうなります。

  1. 何と何を比較したのか。プラグインが10個付いたunifiedチェーンとプラグインなしのネイティブパイプラインを比較すれば、それはパーサー性能ではなくプラグインの数を測っていることになります。
  2. ビルド全体なのかパイプラインだけなのか。パイプラインが5倍速くなっても、その段階が全体の15パーセントなら全体は12パーセントしか縮みません。アムダールの法則はここでもそのまま当てはまります。
  3. コールドかウォームか。キャッシュが残った状態での再ビルドは別のものを測っています。
  4. 出力が同一かどうか。結果のHTMLが異なれば、速度比較自体が成立しません。パーサーを乗り換えるときは、レンダー結果を必ずdiffで突き合わせる必要があります。

4つ目が特に重要です。pulldown-cmark系とmicromark系は、CommonMark準拠度がどちらも高くても、エッジケースでは出力が微妙に異なることがあります。乗り換える前に、全文書を両方でレンダーして比較する手順を用意しておくとよいでしょう。

# 乗り換え前後のレンダー結果を全量突き合わせる最小限の手順
node scripts/render-all.mjs --engine=unified --out /tmp/html-unified
node scripts/render-all.mjs --engine=satteri --out /tmp/html-satteri
diff -ru /tmp/html-unified /tmp/html-satteri | head -100

数千ページ規模のサイトが実際にすべきこと

効果の大きさで優先順位を並べると、ツールの乗り換えは思ったより後回しになります。

優先度1、処理するページ数を減らしてください。パイプラインを速くすることより、そもそも動かさないことのほうが常に速いのです。コンテンツファイルのハッシュをキーにしてコンパイル結果をキャッシュし、変更されたファイルだけを処理し直すインクリメンタルな経路を作りましょう。CIではこのキャッシュをリモートで共有すれば、コールドなランナーでも恩恵があります。キャッシュキーを正確に設計する方法はモノレポCIキャッシュ戦略の回で扱いました。

優先度2、プリレンダリングとハイライティングに手を入れてください。先に述べたとおり、ページ数が多いほどこちらが支配的になります。ハイライターが読み込む文法とテーマを実際に使うものだけに絞り、ハイライト結果はコンテンツハッシュ基準でキャッシュしましょう。

優先度3、プラグインチェーンを整理してください。先の計測スクリプトで上位3つを見つけ、取り除くかまとめましょう。ツールを変えずに得られる改善なのでリスクがありません。

優先度4、そのうえでパイプラインの乗り換えを検討してください。ここまでの順序を踏んでもなおパースと変換が上位に残っているなら、satteriのようなネイティブコアが本当に答えになるかもしれません。検討方法はひとつです — 自分の文書集合で直接測り、出力を突き合わせることです。

そして、導入するタイミングについての判断も必要です。satteriは新しいプロジェクトであり、公式ドキュメントにはまだ埋まっていない部分があり、この記事の時点では安定版の1.0に達していません。ドキュメントサイトのレンダリング精度は取り返しがつきにくい種類のリスクなので、大きなプロダクションサイトであれば、並行レンダー比較を数週間走らせてから切り替えるほうが合理的です。逆に、新しく立ち上げるドキュメントサイトでプラグイン依存が少ないなら、いま採用しても失うものは大きくありません。

おわりに — 速いパーサーより動かないパーサーが速い

satteriは実際の問題を狙ったプロジェクトです。JavaScriptのMarkdownパイプラインが遅い理由は、個々のコードが悪いからではなく、プラグインごとにツリーを何度も巡るという構造そのものがコストだからです。パーサーと拡張機能をネイティブの単一パスに移すことは、その構造に対する正直な対応と言えます。

  • まず測りましょう。--cpu-profとプラグインごとの計測があれば、30分以内にボトルネックが見つかります。
  • 数千ページ規模のサイトでは、プリレンダリングとコードハイライティングがパースより大きいことが多いものです。この順序を逆にしないでください。
  • インクリメンタルビルドとコンテンツハッシュキャッシュが常に最優先です。動かさないことより速いものはありません。
  • satteriに移行すると、unifiedエコシステムのプラグインは一緒には移行できません。その資産の大きさこそが実際の判断基準です。
  • 出回っている2倍という数値は2次メディアからの引用であり、再現条件は公開されていません。自分のリポジトリで直接測り、レンダー結果をdiffで突き合わせてください。

ビルド時間の問題の大半は、より速いツールではなく、より少ない作業によって解決します。ツールの乗り換えは、そのあとに来る選択肢です。

参考資料