Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions ui-tui/scripts/bench-streaming-md.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
// Benchmark: streamed-markdown render strategies for the TUI.
//
// Replays a newline-terminated, block-heavy synthetic stream at width 80
// through a real Ink render (renderSync + rerender per update) and compares:
//
// naive — <Md text={full}/> per update (re-tokenizes everything)
// monolithic — the previous StreamingMd: one memoized stable-prefix <Md>
// plus a tail <Md>. O(total) fence scan per update and a
// full prefix re-parse every time the boundary advances.
// per-block — current StreamingMd: append-only settled block array +
// incremental scanner. Each block parses exactly once.
//
// Each strategy/size pair runs in its OWN child process (orchestrator mode)
// so the parse-tree LRU in markdown.tsx and GC pressure from one strategy
// can't distort another's numbers. Each run also gets a unique text salt and
// a fresh theme object (its own WeakMap cache bucket).
//
// Run: npx tsx scripts/bench-streaming-md.tsx
// Single case: npx tsx scripts/bench-streaming-md.tsx <naive|monolithic|per-block> <blocks>

import { execFileSync } from 'child_process'
import { PassThrough } from 'stream'

import { Box, renderSync } from '@hermes/ink'
import React, { memo, useRef } from 'react'

import { Md } from '../src/components/markdown.js'
import { StreamingMd } from '../src/components/streamingMarkdown.js'
import { DEFAULT_THEME } from '../src/theme.js'

// ---- previous implementation (monolithic stable prefix), for comparison ----

const fenceOpenAt = (s: string, end: number) => {
let codeOpen = false
let mathOpen = false
let mathOpener: '$$' | '\\[' | null = null
let i = 0

while (i < end) {
const nl = s.indexOf('\n', i)
const lineEnd = nl < 0 || nl > end ? end : nl
const line = s.slice(i, lineEnd).trim()

if (/^(?:`{3,}|~{3,})/.test(line)) {
codeOpen = !codeOpen
} else if (!codeOpen) {
if (!mathOpen && /^\$\$/.test(line)) {
if (!(line.length >= 4 && /\$\$$/.test(line))) {
mathOpen = true
mathOpener = '$$'
}
} else if (!mathOpen && /^\\\[/.test(line)) {
if (!/\\\]$/.test(line)) {
mathOpen = true
mathOpener = '\\['
}
} else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
mathOpen = false
mathOpener = null
} else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
mathOpen = false
mathOpener = null
}
}

if (nl < 0 || nl >= end) {
break
}

i = nl + 1
}

return codeOpen || mathOpen
}

const findStableBoundaryOld = (text: string) => {
let idx = text.length

while (idx > 0) {
const boundary = text.lastIndexOf('\n\n', idx - 1)

if (boundary < 0) {
return -1
}

const splitAt = boundary + 2

if (!fenceOpenAt(text, splitAt)) {
return splitAt
}

idx = boundary
}

return -1
}

const MonolithicStreamingMd = memo(function MonolithicStreamingMd({
t,
text
}: {
t: typeof DEFAULT_THEME
text: string
}) {
const stablePrefixRef = useRef('')

if (!text.startsWith(stablePrefixRef.current)) {
stablePrefixRef.current = ''
}

const boundary = findStableBoundaryOld(text)

if (boundary > stablePrefixRef.current.length) {
stablePrefixRef.current = text.slice(0, boundary)
}

const stablePrefix = stablePrefixRef.current
const unstableSuffix = text.slice(stablePrefix.length)

if (!stablePrefix) {
return <Md t={t} text={unstableSuffix} />
}

if (!unstableSuffix) {
return <Md t={t} text={stablePrefix} />
}

return (
<Box flexDirection="column">
<Md t={t} text={stablePrefix} />
<Md t={t} text={unstableSuffix} />
</Box>
)
})

// ---- synthetic stream ----

const makeBlocks = (count: number, salt: string) => {
const blocks: string[] = []

for (let i = 0; i < count; i++) {
switch (i % 4) {
case 0:
blocks.push(`Paragraph ${salt}-${i} explaining step ${i} with **bold** and \`code\` inline.\n`)

break

case 1:
blocks.push(`- item one ${salt}-${i}\n- item two with _emphasis_\n- item three\n`)

break

case 2:
blocks.push(`\`\`\`ts\nconst v${i} = compute${salt}(${i})\nif (v${i} > 0) {\n emit(v${i})\n}\n\`\`\`\n`)

break

default:
blocks.push(`### Heading ${salt}-${i}\n\nSome follow-up prose for section ${i}.\n`)
}
}

return blocks
}

// Newline-terminated updates: the stream grows one line per rerender.
const makeUpdates = (blocks: string[]) => {
const full = blocks.join('\n')
const updates: string[] = []

let pos = 0

while (pos < full.length) {
const nl = full.indexOf('\n', pos)

pos = nl < 0 ? full.length : nl + 1
updates.push(full.slice(0, pos))
}

return updates
}

const nullStream = () => {
const s = new PassThrough()

Object.assign(s, { columns: 80, isTTY: false, rows: 24 })
s.on('data', () => {})

return s
}

const bench = (
label: string,
updates: string[],
node: (t: typeof DEFAULT_THEME, text: string) => React.ReactNode,
captureSeries = false
) => {
// Fresh theme per run → fresh (collectable) mdCache bucket.
const runTheme = { ...DEFAULT_THEME }

const instance = renderSync(node(runTheme, ''), {
patchConsole: false,
stderr: nullStream() as unknown as NodeJS.WriteStream,
stdin: nullStream() as unknown as NodeJS.ReadStream,
stdout: nullStream() as unknown as NodeJS.WriteStream
})

const times: number[] = []
const start = performance.now()

let n = 0

for (const text of updates) {
const t0 = captureSeries ? performance.now() : 0

instance.rerender(node(runTheme, text))

if (captureSeries) {
times.push(performance.now() - t0)
}

// Something in the render path emits performance measures; unbounded,
// the entry buffer itself becomes a memory leak over thousands of
// rerenders and skews long runs.
if (++n % 64 === 0) {
performance.clearMeasures()
performance.clearMarks()
}
}

const elapsed = performance.now() - start

instance.unmount()
instance.cleanup()

return { elapsed, label, times }
}

const STRATEGIES = {
monolithic: (t: typeof DEFAULT_THEME, text: string) => <MonolithicStreamingMd t={t} text={text} />,
naive: (t: typeof DEFAULT_THEME, text: string) => <Md t={t} text={text} />,
'per-block': (t: typeof DEFAULT_THEME, text: string) => <StreamingMd t={t} text={text} />
} as const

const [strategyArg, sizeArg] = process.argv.slice(2)

if (strategyArg === 'series') {
// Series mode: per-append render times for one strategy/size, as JSON.
// Usage: npx tsx scripts/bench-streaming-md.tsx series <strategy> <blocks>
const [, seriesStrategy, seriesSize] = process.argv.slice(2)
const size = Number(seriesSize)
const updates = makeUpdates(makeBlocks(size, `${seriesStrategy}${size}`))
const { elapsed, times } = bench(
seriesStrategy!,
updates,
STRATEGIES[seriesStrategy as keyof typeof STRATEGIES],
true
)

console.log(JSON.stringify({ appends: updates.length, elapsed, times }))
} else if (strategyArg) {
// Child mode: run one strategy/size and print elapsed ms as JSON.
const size = Number(sizeArg)
const updates = makeUpdates(makeBlocks(size, `${strategyArg}${size}`))
const { elapsed } = bench(strategyArg, updates, STRATEGIES[strategyArg as keyof typeof STRATEGIES])

console.log(JSON.stringify({ appends: updates.length, elapsed }))
} else {
// Orchestrator mode: one child process per strategy/size.
const sizes = [32, 128, 512]

const run = (strategy: string, size: number): { appends: number; elapsed: number } => {
const out = execFileSync('npx', ['tsx', 'scripts/bench-streaming-md.tsx', strategy, String(size)], {
encoding: 'utf8',
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=8192' },
timeout: 3_600_000
})

return JSON.parse(out.trim().split('\n').at(-1)!)
}

const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${ms.toFixed(1)} ms`)

console.log(
'| Blocks | Append calls | naive (full Md) | monolithic prefix | per-block (new) | new vs naive | new vs monolithic |'
)
console.log(
'|--------|--------------|-----------------|-------------------|-----------------|--------------|-------------------|'
)

for (const size of sizes) {
const naive = run('naive', size)
const mono = run('monolithic', size)
const perBlock = run('per-block', size)

console.log(
`| ${size} | ${perBlock.appends} | ${fmt(naive.elapsed)} | ${fmt(mono.elapsed)} | ${fmt(perBlock.elapsed)} | ${(
naive.elapsed / perBlock.elapsed
).toFixed(1)}x | ${(mono.elapsed / perBlock.elapsed).toFixed(1)}x |`
)
}
}
Loading
Loading