diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2470ef1..f9ef9fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,3 +75,73 @@ downstream source-map integration together. The workflow uses a read-only token and disables persisted checkout credentials because it executes downstream repository code. + +## Source-map heap profiling + +The `profile:source-map` script captures V8 heap snapshots at specific +construction phases to verify that lazy indexes (`lineStarts`, +`sourceGapPrefix`) do not exist in retained heap until first use. + +### Usage + +```bash +# Capture a snapshot +pnpm run profile:source-map -- segments build +pnpm run profile:source-map -- segments raw +pnpm run profile:source-map -- segments range + +# Open in Chrome DevTools → Memory → Load .heapsnapshot +``` + +**Fixtures:** + +| Fixture | Shape | Purpose | +|---|---|---| +| `many-nodes` | 10k short paragraphs | high node count, low segment density | +| `segments` | 256 KiB `&\(` repeats | maximum segment count per node | +| `fenced-code` | 1 MiB code block | single large code node with source-map segments | +| `urls` | 1000 link definitions | URL segment construction | + +**Phases:** + +| Phase | What runs | What should NOT exist in heap | +|---|---|---| +| `build` | `parseMdWithSourceMap()` only | `lineStarts`, `sourceGapPrefix` | +| `raw` | + `getRaw()` on every mapped node | `lineStarts`, `sourceGapPrefix` | +| `range` | + `getSourceRange()` on every mapped value node | (indexes now created on demand) | + +### What to look for in DevTools + +**Retained size dominators** — in the Dominators view, check: + +- AST nodes (`type`, `position`, `children`, `value`) +- Source strings (the original Markdown input) +- Segment objects (`valueStart`, `valueEnd`, `sourceStart`, `sourceEnd`, `kind`) +- Segment arrays (one per mapped node) +- WeakMap state (sourceMap closure → WeakMap → values) +- Lazy indexes (`lineStarts` array, `sourceGapPrefix` arrays) + +The build → raw → range progression should show `lineStarts` and +`sourceGapPrefix` (for multi-segment ranges) appearing only in the +`range` phase. If they appear +in `build` or `raw`, the lazy design is broken. + +### Interpretation + +The goal is a retained-heap breakdown: + +``` +retained heap +├── AST ??% +├── source strings ??% +├── segment objects ??% +├── segment arrays ??% +├── WeakMap state ??% +└── lazy indexes ??% +``` + +If AST + source strings + parser strings dominate, source-map memory +optimization is not worthwhile. If segment objects/arrays are large, +consider a more compact segment representation. + +**Rule: no heap dominator / retained-size evidence → no memory optimization PRs.** diff --git a/package.json b/package.json index 00df19e..b059c4b 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "test:package": "publint && node scripts/test-package.mjs", "prebench:source-map": "pnpm run build", "bench:source-map": "node scripts/bench-source-map.mjs", + "preprofile:source-map": "pnpm run build", + "profile:source-map": "node --expose-gc scripts/profile-source-map-heap.mjs", "test:update": "jest --updateSnapshot", "prepublishOnly": "pnpm run lint && pnpm run build && pnpm run test" }, diff --git a/scripts/profile-source-map-heap.mjs b/scripts/profile-source-map-heap.mjs new file mode 100644 index 0000000..1962915 --- /dev/null +++ b/scripts/profile-source-map-heap.mjs @@ -0,0 +1,144 @@ +// Heap-profile source-map retained structures. +// +// Run: pnpm run profile:source-map -- +// e.g. pnpm run profile:source-map -- segments build +// +// Fixtures: +// many-nodes – 10k small text nodes (flat list) +// segments – 256 KiB high segment-density (&\( repeats) +// fenced-code – 1 MiB fenced code block (single code node with segments) +// urls – 1000 link definitions +// +// Phases: +// build – parse only (no lazy indexes should exist) +// raw – parse + getRaw on every mapped node +// range – parse + getSourceRange on every mapped node +// +// Outputs a .heapsnapshot to temp/heap-profile/. +// Requires Node >= 19 with --expose-gc. +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import v8 from 'node:v8'; + +const require = createRequire(import.meta.url); +const { parseMdWithSourceMap } = require('../dist/lint-md-parser.cjs'); + +// ── Fixtures ──────────────────────────────────────────────────────────── + +function fixtureManyNodes() { + // 10k short paragraphs, each becomes a text node. + // Blank lines required — consecutive lines merge into one paragraph. + const lines = []; + for (let i = 0; i < 10_000; i++) lines.push(`node ${i}`, ''); + return lines.join('\n'); +} + +function fixtureSegments() { + // 256 KiB of alternating & \( — maximizes segment count per node. + const unit = '&\\('; + const count = Math.round((256 * 1024) / unit.length); + return unit.repeat(count); +} + +function fixtureFencedCode() { + // 1 MiB code inside a fenced block — one large code node with + // code-value source mapping. + const line = 'x'.repeat(80); + const target = 1024 * 1024; + const count = Math.ceil(target / (line.length + 1)); + return '```\n' + `${line}\n`.repeat(count) + '```'; +} + +function fixtureUrls() { + // 1000 link definitions with short URLs. + const defs = []; + for (let i = 0; i < 1000; i++) { + defs.push(`[text${i}]: /path/to/resource-${i}?q=${i} "title ${i}"`); + } + return defs.join('\n'); +} + +const FIXTURES = { + 'many-nodes': fixtureManyNodes, + segments: fixtureSegments, + 'fenced-code': fixtureFencedCode, + urls: fixtureUrls, +}; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** Collect all mapped text/inlineCode/code/link/definition nodes. */ +function collectMappedNodes(root) { + const out = []; + (function walk(n) { + if ( + n.type === 'text' + || n.type === 'inlineCode' + || n.type === 'code' + || n.type === 'link' + || n.type === 'definition' + ) { + out.push(n); + } + for (const c of n.children || []) walk(c); + })(root); + return out; +} + +// ── Main ──────────────────────────────────────────────────────────────── + +const [, , fixtureName, phase] = process.argv; + +if (!fixtureName || !phase || !FIXTURES[fixtureName] || !['build', 'raw', 'range'].includes(phase)) { + console.error( + 'Usage: node --expose-gc scripts/profile-source-map-heap.mjs' + + ` \n` + + ` fixtures: ${Object.keys(FIXTURES).join(', ')}\n` + + ` phases: build, raw, range`, + ); + process.exit(1); +} + +const md = FIXTURES[fixtureName](); + +// Phase 1: build (parse only) +const { ast, sourceMap } = parseMdWithSourceMap(md); + +// Phase 2 or 3: exercise the source map +if (phase === 'raw') { + const nodes = collectMappedNodes(ast); + for (const n of nodes) { + sourceMap.getRaw(n); + } +} else if (phase === 'range') { + const nodes = collectMappedNodes(ast).filter( + n => n.type === 'text' || n.type === 'inlineCode' || n.type === 'code', + ); + for (const n of nodes) { + if (!n.value) continue; + try { + sourceMap.getSourceRange(n, 0, n.value.length); + } catch (error) { + // Only non-contiguous ranges (e.g. across blockquote boundaries) + // are expected to fail. Anything else is a real bug. + if (!(error instanceof RangeError)) throw error; + } + } +} + +// Force GC before snapshot. +if (global.gc) global.gc(); + +// Write snapshot. +const outDir = join(process.cwd(), 'temp', 'heap-profile'); +mkdirSync(outDir, { recursive: true }); +const filename = `${fixtureName}-${phase}-${Date.now()}.heapsnapshot`; +const snapshotPath = join(outDir, filename); +v8.writeHeapSnapshot(snapshotPath); + +console.log(`Snapshot written to ${snapshotPath}`); +console.log(` fixture: ${fixtureName}`); +console.log(` phase: ${phase}`); +console.log(` md size: ${(md.length / 1024).toFixed(1)} KiB (${md.length} chars)`); +console.log(` nodes: ${collectMappedNodes(ast).length} mapped`);