154 lines
6.1 KiB
JavaScript
154 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Benchmark: cost of the rich-markdown Table-of-Contents parse that fires on
|
|
// every debounced content change while typing.
|
|
//
|
|
// Before the fix (RichMarkdownEditor.tsx), buildMarkdownTableOfContents() ran a
|
|
// full-document remark parse on EVERY content change even when the TOC panel was
|
|
// closed (the default), then discarded the result. This benchmark mirrors that
|
|
// parse workload across document sizes and reports the main-thread time spent
|
|
// per "typing burst" so the wasted work is quantified.
|
|
//
|
|
// The fix gates the memo on showTableOfContents, so when the panel is closed the
|
|
// per-burst cost drops to ~0 (a stable empty array, no parse). This script
|
|
// measures the "panel closed" cost that the gate eliminates.
|
|
import { performance } from 'node:perf_hooks'
|
|
import remarkFrontmatter from 'remark-frontmatter'
|
|
import remarkGfm from 'remark-gfm'
|
|
import remarkParse from 'remark-parse'
|
|
import { unified } from 'unified'
|
|
|
|
const DOC_HEADINGS = Number.parseInt(process.env.ORCA_TOC_BENCH_HEADINGS ?? '400', 10)
|
|
const PARAGRAPHS_PER_HEADING = Number.parseInt(process.env.ORCA_TOC_BENCH_PARAS ?? '6', 10)
|
|
// Number of debounced content changes in a sustained typing burst. The editor
|
|
// debounces serialize at 300ms, so ~200 changes ≈ a minute of steady typing.
|
|
const CONTENT_CHANGES = Number.parseInt(process.env.ORCA_TOC_BENCH_CHANGES ?? '200', 10)
|
|
const WARMUP = Number.parseInt(process.env.ORCA_TOC_BENCH_WARMUP ?? '5', 10)
|
|
|
|
for (const [name, value] of [
|
|
['ORCA_TOC_BENCH_HEADINGS', DOC_HEADINGS],
|
|
['ORCA_TOC_BENCH_PARAS', PARAGRAPHS_PER_HEADING],
|
|
['ORCA_TOC_BENCH_CHANGES', CONTENT_CHANGES],
|
|
['ORCA_TOC_BENCH_WARMUP', WARMUP]
|
|
]) {
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new Error(`${name} must be a positive integer, received ${value}`)
|
|
}
|
|
}
|
|
|
|
// Mirror of buildMarkdownTableOfContents()'s parse + heading walk
|
|
// (src/renderer/src/components/editor/markdown-table-of-contents.ts). Kept inline
|
|
// so the benchmark exercises the real remark pipeline without bundling the TS.
|
|
function buildMarkdownTableOfContentsLike(markdown) {
|
|
const tree = unified()
|
|
.use(remarkParse)
|
|
.use(remarkGfm)
|
|
.use(remarkFrontmatter, ['yaml', 'toml'])
|
|
.parse(markdown)
|
|
const headings = []
|
|
const visit = (node) => {
|
|
if (node.type === 'heading' && typeof node.depth === 'number') {
|
|
let title = ''
|
|
const collect = (n) => {
|
|
if (typeof n.value === 'string') {
|
|
title += n.value
|
|
}
|
|
for (const child of n.children ?? []) {
|
|
collect(child)
|
|
}
|
|
}
|
|
collect(node)
|
|
if (title) {
|
|
headings.push({ depth: node.depth, title })
|
|
}
|
|
}
|
|
for (const child of node.children ?? []) {
|
|
visit(child)
|
|
}
|
|
}
|
|
visit(tree)
|
|
return headings
|
|
}
|
|
|
|
function buildDocument(headings, parasPerHeading) {
|
|
const lines = ['---', 'title: Benchmark Document', 'tags: [perf, toc]', '---', '']
|
|
for (let h = 0; h < headings; h += 1) {
|
|
const level = (h % 3) + 1
|
|
lines.push(`${'#'.repeat(level)} Section ${h} \`code\` **bold** [link](https://example.com)`)
|
|
lines.push('')
|
|
for (let p = 0; p < parasPerHeading; p += 1) {
|
|
lines.push(
|
|
`Paragraph ${p} for section ${h} with *emphasis*, \`inline code\`, and ` +
|
|
'some filler text to give the parser realistic body content to walk over.'
|
|
)
|
|
lines.push('')
|
|
}
|
|
if (h % 5 === 0) {
|
|
lines.push('| Col A | Col B | Col C |', '| --- | --- | --- |', '| 1 | 2 | 3 |', '')
|
|
}
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function median(values) {
|
|
const sorted = [...values].sort((a, b) => a - b)
|
|
const mid = Math.floor(sorted.length / 2)
|
|
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
|
}
|
|
|
|
function measureDoc(headings, parasPerHeading) {
|
|
const baseDoc = buildDocument(headings, parasPerHeading)
|
|
// Simulate typing: each content change appends one character so `content`
|
|
// changes identity every time (matching the memo dependency in the editor).
|
|
const perChange = []
|
|
for (let i = 0; i < WARMUP + CONTENT_CHANGES; i += 1) {
|
|
const doc = `${baseDoc}\nedit-${i}`
|
|
const t0 = performance.now()
|
|
const toc = buildMarkdownTableOfContentsLike(doc)
|
|
const elapsed = performance.now() - t0
|
|
if (i >= WARMUP) {
|
|
perChange.push(elapsed)
|
|
}
|
|
if (toc.length === 0) {
|
|
throw new Error('benchmark produced an empty TOC; document generation is broken')
|
|
}
|
|
}
|
|
const total = perChange.reduce((sum, value) => sum + value, 0)
|
|
return {
|
|
bytes: Buffer.byteLength(baseDoc, 'utf8'),
|
|
headings,
|
|
perChangeMedianMs: median(perChange),
|
|
perChangeMaxMs: Math.max(...perChange),
|
|
burstTotalMs: total
|
|
}
|
|
}
|
|
|
|
const sizes = [
|
|
{ headings: Math.round(DOC_HEADINGS / 8), paras: PARAGRAPHS_PER_HEADING },
|
|
{ headings: Math.round(DOC_HEADINGS / 2), paras: PARAGRAPHS_PER_HEADING },
|
|
{ headings: DOC_HEADINGS, paras: PARAGRAPHS_PER_HEADING }
|
|
]
|
|
|
|
console.log('Markdown TOC parse benchmark (cost incurred per content change while typing)')
|
|
console.log(
|
|
`changes/burst=${CONTENT_CHANGES} warmup=${WARMUP} paras/heading=${PARAGRAPHS_PER_HEADING}\n`
|
|
)
|
|
console.log(
|
|
' doc size │ headings │ per-change median │ per-change max │ burst total (closed-panel waste)'
|
|
)
|
|
console.log(
|
|
' ─────────┼──────────┼───────────────────┼────────────────┼──────────────────────────────────'
|
|
)
|
|
for (const size of sizes) {
|
|
const result = measureDoc(size.headings, size.paras)
|
|
const kib = (result.bytes / 1024).toFixed(0).padStart(5)
|
|
const headingsCol = String(result.headings).padStart(8)
|
|
const med = `${result.perChangeMedianMs.toFixed(2)} ms`.padStart(17)
|
|
const max = `${result.perChangeMaxMs.toFixed(2)} ms`.padStart(14)
|
|
const burst = `${result.burstTotalMs.toFixed(0)} ms`.padStart(10)
|
|
console.log(` ${kib} KiB │ ${headingsCol} │ ${med} │ ${max} │ ${burst}`)
|
|
}
|
|
console.log(
|
|
'\nWith the fix, every row above costs ~0 ms while the TOC panel is closed' +
|
|
' (the default state): the parse is skipped and a stable empty array is returned.'
|
|
)
|