-
Notifications
You must be signed in to change notification settings - Fork 0
Source‐Map Performance Benchmark
A/B benchmark for measuring CPU time and memory across multiple source-map workloads. Used to validate the optimization round in PRs #108–#111.
| Item | Value |
|---|---|
| OS | Ubuntu 24.04.2 LTS (x86_64) |
| CPU | AMD Ryzen 7 8745HS w/ Radeon 780M |
| Node.js | v24.15.0 |
| pnpm | 11.0.9 |
| Date | 2026-09-07 |
All measurements ran on the same machine in the same session. The before/after versions share the same benchmark script (from the after commit) to ensure identical workloads.
| Commit | PR | Description | |
|---|---|---|---|
| Before | 3de49e7 |
— | State before #108 |
| After | e6c9fdd |
#111 merged | After #108 + #109 + #110 + #111 |
| PR | Change | Primary effect |
|---|---|---|
| #108 | Reuse resolved segment indices in range queries | 4 → 2 binary searches per getSourceRange()
|
| #109 | Lazy-build lineStarts in parseMdWithSourceMap()
|
Skip number[] allocation + Markdown scan for getRaw()-only callers |
| #110 | Lazy-build sourceGapPrefix on first multi-segment getSourceRange()
|
Skip number[] allocation for single-segment and non-query callers |
| #111 | Eliminate temporary string/array reconstruction during validation | No spread array, no rebuilt value string, no substring slices |
| Workload | Generator | Validates |
|---|---|---|
| plain text 64/256 KiB | Lines of ASCII text | Baseline parse + query |
| text segments 256 KiB | Alternating &\( (2 segments per 10 chars) |
Segment-heavy node — #108 binary search, #110 prefix allocation |
| fenced code 256 KiB / 1 MiB |
console.log(...) lines inside triple backticks |
Large code block — #111 temporary string elimination |
| inline code 64/256 KiB | Repeated `&` spans |
Entity/escape segments — #111 spread array elimination |
| URLs x1000 | Links with query params and entities | URL mapping — #111 rebuilt value elimination |
| 1k / 10k paragraphs | Short text nodes separated by blank lines | Many small nodes — #109/#110 per-node allocation pressure |
-
build:
parseMdWithSourceMap(md)— full parse + source-map construction -
getRaw: Call
sourceMap.getRaw()on every AST node (after a separateparseMdWithSourceMap()call) -
getSourceRange: Call
sourceMap.getSourceRange(n, 0, n.value.length)on every text node (after a separateparseMdWithSourceMap()call)
Note: getRaw and getSourceRange are timed separately from build. The getRaw timing measures only the query, not the parse. To measure the end-to-end parse + getRaw cost (relevant for #109), see the dedicated rerun section below.
-
Peak RSS: Each workload was measured in a separate Node.js process via
/usr/bin/time -v→Maximum resident set size (kbytes). Theab-benchmark.mjsscript runs all workloads in one process, so its RSS column reflects the process-level maximum across all workloads, not per-workload RSS. The Peak RSS table below was produced by running each workload in isolation. -
Heap delta:
process.memoryUsage()with--expose-gc+ manualgc()call before snapshot. This measures post-GC retained heap, not transient allocation volume. For #111's allocation elimination, Peak RSS is a more faithful signal than post-GC heap.
# Create worktrees
git worktree add /tmp/parser-before 3de49e76044b
git worktree add /tmp/parser-after e6c9fdd95df8
# Build both
cd /tmp/parser-before && pnpm install --frozen-lockfile && pnpm run build
cd /tmp/parser-after && pnpm install --frozen-lockfile && pnpm run build
# Run with the AFTER repo's benchmark script (same workload for both)
cd /tmp/parser-after
node --expose-gc scripts/ab-benchmark.mjs /tmp/parser-before/dist/lint-md-parser.cjs
node --expose-gc scripts/ab-benchmark.mjs /tmp/parser-after/dist/lint-md-parser.cjsAlways use the newer version's benchmark script to ensure identical workloads.
| Metric | Before | After | Delta |
|---|---|---|---|
| random range queries (10k iters) | 5.50 ms | 4.63 ms | -16% |
| query full value range (10k iters) | 1.10 ms | 1.05 ms | -5% |
| random single-unit queries (256K iters) | 25.83 ms | 31.18 ms | noise * |
| query every code unit (256K iters) | 10.79 ms | 11.34 ms | noise |
* The initial single-pass A/B showed +21% for random single-unit. A 10-sample A/B rerun (alternating before/after, one process per sample) resolved this to -23.6% (before median 50.3 ms, after median 38.4 ms). The initial +21% was scheduling/JIT noise.
Two data points from the initial A/B were flagged as anomalies. Both were rerun with 10 alternating samples (A B A B A B A B A B), one fresh Node.js process per sample, median of 5 per side.
| Samples (ms) | Median | |
|---|---|---|
| Before | 452.6, 416.3, 397.4, 217.7, 194.5 | 397.4 ms |
| After | 243.6, 233.3, 773.8, 373.5, 399.5 | 373.5 ms |
Delta: -6.0%. The initial +77% was scheduling noise (variance across samples is very high for 1 MiB workloads). Consistent with fenced code 256 KiB (-4%).
| Samples (ms) | Median | |
|---|---|---|
| Before | 56.1, 43.2, 53.6, 44.2, 50.3 | 50.3 ms |
| After | 41.1, 33.0, 38.4, 40.0, 34.4 | 38.4 ms |
Delta: -23.6%. The initial +21% was noise. The real result is a significant improvement, consistent with the other query benchmarks.
| Workload | Before | After | Delta |
|---|---|---|---|
| plain text 64 KiB | 56.6 ms | 51.1 ms | -10% |
| plain text 256 KiB | 202.3 ms | 207.3 ms | +2% noise |
| text segments 256 KiB | 305.3 ms | 286.7 ms | -6% |
| fenced code 256 KiB | 42.1 ms | 40.6 ms | -4% |
| fenced code 1 MiB | 218.7 ms | 387.8 ms | noise → -6% (rerun) |
| inline code 64 KiB | 7.5 ms | 6.8 ms | -9% |
| inline code 256 KiB | 28.1 ms | 27.0 ms | -4% |
| URLs x1000 | 40.7 ms | 34.7 ms | -15% |
| 1k paragraphs | 21.5 ms | 20.4 ms | -5% |
| 10k paragraphs | 372.8 ms | 352.6 ms | -5% |
| Workload | Before | After | Delta |
|---|---|---|---|
| 1k paragraphs | 0.3 ms | 0.3 ms | — |
| 10k paragraphs | 2.7 ms | 1.8 ms | -33% * |
* This measures only the getRaw() call, not the preceding parseMdWithSourceMap(). #109 optimizes the parse path (skipping lineStarts construction), not the getRaw() function itself. The -33% is likely measurement variance from the separate parseMdWithSourceMap() call that precedes the timed section. To properly validate #109, measure end-to-end parse + getRaw:
benchCpu('parse+getRaw', () => {
const { ast, sourceMap } = parseMdWithSourceMap(md);
for (const node of allNodes(ast))
try { sourceMap.getRaw(node); } catch {}
});This was not included in the initial benchmark round. The build-time improvements (5–15% across workloads) provide indirect evidence that #109/#110 reduce construction overhead.
| Workload | Before | After | Delta |
|---|---|---|---|
| 10k paragraphs | 83,828 KB (81.9 MB) | 82,884 KB (80.9 MB) | -1.1% |
| text segments 256 KiB | 343,284 KB (335.2 MB) | 349,608 KB (341.4 MB) | +1.8% noise |
| fenced code 256 KiB | 102,120 KB (99.7 MB) | 101,696 KB (99.3 MB) | -0.4% |
| fenced code 1 MiB | 220,524 KB (215.4 MB) | 218,304 KB (213.2 MB) | -1.0% |
| inline code 256 KiB | 97,984 KB (95.7 MB) | 96,024 KB (93.8 MB) | -2.0% |
| URLs x1000 | 108,784 KB (106.2 MB) | 105,800 KB (103.3 MB) | -2.7% |
Note: Node.js has ~80 MB base RSS. The signal is in the delta. Peak RSS captures some transient allocation pressure that post-GC heap misses.
| Workload | Before | After | Delta |
|---|---|---|---|
| fenced code 256 KiB | 1.6 MB | 0.4 MB | -75% |
| URLs x1000 | 13.0 MB | 8.4 MB | -35% |
These measure post-GC retained heap, not transient allocation volume. #111 eliminates temporary strings/arrays that are discarded before GC runs. The improvement is real but this metric understates the allocation reduction. Peak RSS is a more faithful signal for allocation-sensitive optimizations.
| Category | Workload | Validates | Before | After | Delta |
|---|---|---|---|---|---|
| Query CPU | random range 256 KiB | #108 | 5.50 ms | 4.63 ms | -16% |
| Query CPU | random single-unit 256 KiB (rerun) | #108 | 50.3 ms | 38.4 ms | -24% |
| Build CPU | URLs x1000 | #111 | 40.7 ms | 34.7 ms | -15% |
| Build CPU | plain text 64 KiB | #109/#110 | 56.6 ms | 51.1 ms | -10% |
| Build CPU | text segments 256 KiB | #108/#110 | 305.3 ms | 286.7 ms | -6% |
| Build CPU | fenced code 1 MiB (rerun) | #111 | 397.4 ms | 373.5 ms | -6% |
| Build CPU | 10k paragraphs | #109/#110 | 372.8 ms | 352.6 ms | -5% |
| Peak RSS | URLs x1000 | #111 | 106.2 MB | 103.3 MB | -2.7% |
| Peak RSS | inline code 256 KiB | #111 | 95.7 MB | 93.8 MB | -2.0% |
| Peak RSS | fenced code 1 MiB | #111 | 215.4 MB | 213.2 MB | -1.0% |
| Regression | — | — | — | — | None confirmed |
-
Measurement noise: Even with 3-sample median and A/B alternation, single-machine benchmarks on shared hardware have inherent variance. The rerun section shows how initial anomalies resolved with more samples.
-
GC timing: Post-GC heap delta depends on when V8 triggers GC.
--expose-gc+ manualgc()reduces variance but doesn't eliminate it. This metric measures retained heap, not transient allocation. -
Synthetic workloads: All inputs are algorithmically generated. Real-world Markdown may show different ratios.
-
RSS includes Node.js overhead: Peak RSS includes ~80 MB Node.js runtime baseline. The signal is in the delta.
-
getRaw timing: The
getRawbenchmark measures only the query, not the parse. #109's optimization is in the parse path. A dedicated end-to-endparse + getRawbenchmark would be needed to directly measure #109's impact on this path. -
Single-machine results: All data from one AMD Ryzen 7 / Ubuntu 24.04 session. Different architectures (ARM, Intel) or OS may show different profiles.
See scripts/ab-benchmark.mjs in the repository for the full benchmark script.