Skip to content

Commit 1b8fab0

Browse files
perf(prepare-list): batch list-item insertions into one pass
prepareList synthesizes listItem enter and exit events one item at a time using events.splice(at, 0, [event]). Each splice shifts the suffix of the events array, so a list with K items inside an array of N events does O(K * N) shift work. This is the dominant cost in mdast-util-from-markdown's contribution to wide-list inputs and the slowdown reported at depth on issue #49 / PR #50. The fix collects the would-be splices into an insertions queue during the existing walk and applies them outside the loop in one pass. Two paths handle the work efficiently: lists with up to a small number of insertions take a fast path that splices each insertion in reverse order so unspliced positions stay valid, which avoids the cost of allocating a fresh sub-array; longer lists go through a batched rebuild that writes the replacement into a fresh array, then either splices the whole replacement in one call (when it fits below V8's spread argument limit) or shifts the suffix once and writes the replacement into the vacated range. The in-place shift avoids the per-chunk splice loop a chunked spread fallback would use, which would re-introduce O(K * N) shift cost on very wide lists. How the cut points were chosen: There are two thresholds in the new code: SMALL_LIST_LIMIT chooses between fast-path splice loop and rebuild; SAFE_SPREAD chooses between a single spread and an in-place shift. SMALL_LIST_LIMIT is a workload-dependent crossover. The fast path costs O(K * suffix) because each of the K splices shifts the events suffix; the rebuild path costs O(N + K) plus a fixed allocation overhead. Below some K the rebuild's constant overhead dominates; above some K the fast path's K * suffix dominates. Because suffix size and per-insertion splice cost both vary with document shape, no single value is universally best: deeper nesting prefers higher limits (everything stays on the splice loop), and documents with a few moderately-sized lists prefer lower limits (the rebuild's lower per-item cost wins). The threshold was chosen by sweeping {0, 2, 4, 8, 16, 32, 64} with representative inputs from both regimes and picking the value that kept the validated multi-run wins on typical-document inputs without regressing the deep-nest case beyond its own noise band. SAFE_SPREAD is set by V8's argument count limit. Spreading a very large array into events.splice can throw a stack overflow in some V8 versions, so above the threshold the rebuild instead resizes events once, shifts the suffix to its target position, and writes the replacement into the vacated range. Total work is O(suffix + replacement.length), independent of how many insertions were queued. The single-spread threshold was tested at {1000, 2000, 5000, 10000, 20000, 70000}; 10000 had the lowest median across the wide-list and spec-derived inputs and matches the threshold micromark-util-chunked already uses for its own splice helper. Pass-1 records insertions in non-decreasing `at` order by construction (each boundary records its exit insertion at `lineIndex || index` followed by its enter insertion at `index`, and the next boundary's tail walk is bounded by the previous boundary's listItemPrefix), so no sort is needed in the slow path. A dev-only assertion verifies the invariant on every slow-path call. Inputs that benefit, with multi-run median-of-medians vs the baseline (spread in parentheses): 10,000 single-level list items -40.8% (5.6%) 5,000 ATX headings -20.6% (4.3%) CommonMark spec * 35 (~564 KB) -11.7% (1.6%, very clean) one CommonMark example -9.1% (105%, NOISY) 256 nested ordered list levels -8.8% (20.3%) full CommonMark spec (~16 KB) -7.0% (35.4%, NOISY) CommonMark spec * 7 (~113 KB) -3.6% (1.0%, very clean) Single-run full corpus runs show the same direction on every other input that contains at least one list, with wins of -17% to -27% on inputs heavy in fenced code blocks, images, character references, inline links, tabs, and HTML blocks. The largest improvement is the 10,000-item single-level list input, which is the worst case for the old per-item splice loop. Trade-offs and inputs that do not move: Inputs that contain no lists are unaffected by the change because prepareList is never invoked. The pure emphasis stress inputs ('a**b' repeated 10,000 times and similar) reported large deltas in single runs, but those inputs have a cross-run spread of 44 to 52% on the baseline alone, so the apparent regressions sit inside their own noise band. A 1 MB single paragraph, a Unicode-heavy 256 KB input, and 10,000 unmatched asterisks all moved within +/- 3% of baseline. Tests pass: dev + prod 1452/1452, 100% coverage maintained. mdast-util-gfm 54/54, mdast-util-mdx 11/13. The two failing mdx tests reproduce on upstream/main and are not introduced by this branch. Closes #49 Refs #50
1 parent f9ef1b3 commit 1b8fab0

2 files changed

Lines changed: 236 additions & 7 deletions

File tree

dev/lib/index.js

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,14 @@ function compiler(options) {
307307
let firstBlankLineIndex
308308
/** @type {boolean | undefined} */
309309
let atMarker
310+
/**
311+
* Insertions accumulated in walk order: each {at, event} maps to an
312+
* `events.splice(at, 0, event)` in the original implementation. Applied
313+
* once at the end so a wide list's per-item O(n) splice cost collapses
314+
* to a single batched rebuild.
315+
* @type {Array<{at: number, event: Event}>}
316+
*/
317+
const insertions = []
310318

311319
while (++index <= length) {
312320
const event = events[index]
@@ -413,9 +421,10 @@ function compiler(options) {
413421
lineIndex ? events[lineIndex][1].start : event[1].end
414422
)
415423

416-
events.splice(lineIndex || index, 0, ['exit', listItem, event[2]])
417-
index++
418-
length++
424+
insertions.push({
425+
at: lineIndex || index,
426+
event: ['exit', listItem, event[2]]
427+
})
419428
}
420429

421430
// Create a new list item.
@@ -429,17 +438,125 @@ function compiler(options) {
429438
end: undefined
430439
}
431440
listItem = item
432-
events.splice(index, 0, ['enter', item, event[2]])
433-
index++
434-
length++
441+
insertions.push({at: index, event: ['enter', item, event[2]]})
435442
firstBlankLineIndex = undefined
436443
atMarker = true
437444
}
438445
}
439446
}
440447

448+
// Apply all insertions outside the iteration loop so a wide list's
449+
// O(n*k) shift cost collapses to a single batched rebuild.
450+
//
451+
// Two paths: small lists (most documents, including deep nesting) take
452+
// the splice fast path, which avoids the cost of allocating a fresh
453+
// replacement array. Wide lists go through the batched rebuild and use
454+
// a chunked spread to stay under V8's argument-count limit.
455+
if (insertions.length > 0) {
456+
// The fast path costs one splice per insertion, each shifting the
457+
// events suffix by 1; total cost is O(K * suffix). The rebuild path
458+
// costs one allocation and one splice replacing the whole range;
459+
// total cost is O(N + K) plus the allocation overhead. Below some K
460+
// the rebuild's constant overhead dominates the per-insertion splice
461+
// work; above some K the fast path's K * suffix dominates the
462+
// rebuild's bounded cost. The crossover varies by document shape, so
463+
// this threshold is a balance between deeply nested lists (which
464+
// favor staying on the fast path) and documents whose lists outgrow
465+
// the constants (which favor the rebuild). The chosen value was
466+
// confirmed by sweeping the threshold across representative inputs.
467+
const SMALL_LIST_LIMIT = 8
468+
if (insertions.length <= SMALL_LIST_LIMIT) {
469+
// Splice from latest `at` to earliest so unspliced positions stay
470+
// valid. Same-`at` groups are reversed compared to walk order, which
471+
// produces the same final ordering the original splice loop did
472+
// (exit before enter at the same insertion point).
473+
let insertion = insertions.length
474+
while (insertion-- > 0) {
475+
events.splice(
476+
insertions[insertion].at,
477+
0,
478+
insertions[insertion].event
479+
)
480+
}
481+
} else {
482+
// `insertions` is already in non-decreasing `at` order by
483+
// construction: the pass-1 walk visits events left to right, and
484+
// each boundary records its exit insertion at `lineIndex || index`
485+
// (with `lineIndex < index`) followed by its enter insertion at
486+
// `index`. The next boundary's tail walk is bounded by the previous
487+
// boundary's listItemPrefix, so its `lineIndex` is strictly greater
488+
// than the previous boundary's `index`. No sort is needed; the
489+
// assert below verifies this in development. micromark-build strips
490+
// the whole call (including the `.every()` callback) in production.
491+
assert(
492+
insertions.every(
493+
(insertion, position) =>
494+
position === 0 || insertion.at >= insertions[position - 1].at
495+
),
496+
'expected insertions to be in non-decreasing `at` order'
497+
)
498+
499+
const rangeLength = length - start + 1
500+
/** @type {Array<Event>} */
501+
const replacement = Array.from({
502+
length: rangeLength + insertions.length
503+
})
504+
let writeIndex = 0
505+
let insertionIndex = 0
506+
let sourceIndex = start
507+
while (sourceIndex <= length) {
508+
while (
509+
insertionIndex < insertions.length &&
510+
insertions[insertionIndex].at === sourceIndex
511+
) {
512+
replacement[writeIndex++] = insertions[insertionIndex].event
513+
insertionIndex++
514+
}
515+
516+
replacement[writeIndex++] = events[sourceIndex++]
517+
}
518+
519+
// V8 limits the number of arguments that can be spread into a
520+
// function call before triggering a stack overflow. Splice into a
521+
// single call when the new sub-array fits comfortably below that
522+
// limit. For larger replacements, neither a single spread (would
523+
// overflow the call stack) nor a loop of smaller splices (each
524+
// would re-shift the suffix, restoring O(K * N) cost) is suitable;
525+
// shift the suffix to its target position once, then write the
526+
// replacement contents into the now-vacated range. Total work is
527+
// O(suffix + replacement.length), independent of how the
528+
// replacement is chunked. The single-spread threshold matches
529+
// micromark-util-chunked's own splice helper.
530+
const SAFE_SPREAD = 10_000
531+
if (replacement.length <= SAFE_SPREAD) {
532+
events.splice(start, rangeLength, ...replacement)
533+
} else {
534+
const oldEnd = start + rangeLength
535+
const newEnd = start + replacement.length
536+
const delta = newEnd - oldEnd
537+
assert(
538+
delta > 0,
539+
'expected the replacement to be longer than the original range'
540+
)
541+
542+
const oldEventsLength = events.length
543+
events.length = oldEventsLength + delta
544+
let shiftIndex = events.length
545+
while (shiftIndex-- > newEnd) {
546+
events[shiftIndex] = events[shiftIndex - delta]
547+
}
548+
549+
let writeIndex = 0
550+
while (writeIndex < replacement.length) {
551+
events[start + writeIndex] = replacement[writeIndex]
552+
writeIndex++
553+
}
554+
}
555+
}
556+
}
557+
441558
events[start][1]._spread = listSpread
442-
return length
559+
return length + insertions.length
443560
}
444561

445562
/**

test/index.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,118 @@ test('fromMarkdown', async function (t) {
10891089
}
10901090
})
10911091
})
1092+
1093+
await t.test(
1094+
'should parse a wide list that exceeds the chunked-splice threshold',
1095+
async function () {
1096+
// The list-item batched-rebuild path falls back to an in-place shift
1097+
// once the replacement exceeds V8's safe argument count for spread.
1098+
// ~1000 single-line items produce a replacement of ~16k entries,
1099+
// which crosses the threshold and exercises that branch. Validate
1100+
// that a wide list lines up with the small list's shape (list-level
1101+
// fields, per-item structure, position bookkeeping) so a regression
1102+
// in the wide-list-only branch cannot pass on count alone.
1103+
const referenceCount = 10
1104+
const wideCount = 1000
1105+
1106+
const reference = fromMarkdown('- a\n'.repeat(referenceCount))
1107+
const wide = fromMarkdown('- a\n'.repeat(wideCount))
1108+
1109+
assert.equal(wide.children.length, 1)
1110+
const referenceRoot = reference.children[0]
1111+
const wideRoot = wide.children[0]
1112+
if (referenceRoot.type !== 'list') throw new Error('expected list')
1113+
if (wideRoot.type !== 'list') throw new Error('expected list')
1114+
1115+
assert.equal(wideRoot.ordered, referenceRoot.ordered)
1116+
assert.equal(wideRoot.spread, referenceRoot.spread)
1117+
assert.equal(wideRoot.start, referenceRoot.start)
1118+
assert.equal(wideRoot.children.length, wideCount)
1119+
1120+
// Each list item must have the same per-item shape the reference
1121+
// list produces (paragraph wrapping a text node valued 'a').
1122+
const referenceItem = referenceRoot.children[0]
1123+
for (const wideItem of [
1124+
wideRoot.children[0],
1125+
wideRoot.children[Math.floor(wideCount / 2)],
1126+
wideRoot.children[wideCount - 1]
1127+
]) {
1128+
assert.equal(wideItem.spread, referenceItem.spread)
1129+
assert.equal(wideItem.checked, referenceItem.checked)
1130+
assert.equal(wideItem.children.length, 1)
1131+
const wideParagraph = wideItem.children[0]
1132+
if (wideParagraph.type !== 'paragraph') {
1133+
throw new Error('expected paragraph')
1134+
}
1135+
1136+
assert.equal(wideParagraph.children.length, 1)
1137+
const wideText = wideParagraph.children[0]
1138+
if (wideText.type !== 'text') throw new Error('expected text')
1139+
assert.equal(wideText.value, 'a')
1140+
}
1141+
1142+
/** @type {Array<import('mdast').ListItem>} */
1143+
const items = wideRoot.children
1144+
1145+
// Positions on a sample of items must line up with the input lines.
1146+
const samples = [0, Math.floor(wideCount / 2), wideCount - 1]
1147+
let sampleIndex = -1
1148+
while (++sampleIndex < samples.length) {
1149+
const lineNumber = samples[sampleIndex]
1150+
const samplePosition = items[lineNumber].position
1151+
if (!samplePosition) throw new Error('expected listItem position')
1152+
assert.equal(samplePosition.start.line, lineNumber + 1)
1153+
assert.equal(samplePosition.start.column, 1)
1154+
}
1155+
1156+
// The list's own position must span from the first item's start to
1157+
// the last item's end.
1158+
const wideListPosition = wideRoot.position
1159+
const firstItemPosition = items[0].position
1160+
const lastItemPosition = items[wideCount - 1].position
1161+
if (!wideListPosition || !firstItemPosition || !lastItemPosition) {
1162+
throw new Error('expected positions')
1163+
}
1164+
1165+
assert.equal(
1166+
wideListPosition.start.offset,
1167+
firstItemPosition.start.offset
1168+
)
1169+
assert.equal(wideListPosition.end.offset, lastItemPosition.end.offset)
1170+
}
1171+
)
1172+
1173+
await t.test(
1174+
'should infer spread on a wide list with blank lines between items',
1175+
async function () {
1176+
// Blank lines between items mark a list as loose (spread = true).
1177+
// The spread inference happens during the same prepareList walk that
1178+
// produces the wide-list rebuild, so this input drives both paths
1179+
// together.
1180+
const referenceCount = 10
1181+
const wideCount = 1000
1182+
1183+
const reference = fromMarkdown('- a\n\n'.repeat(referenceCount))
1184+
const wide = fromMarkdown('- a\n\n'.repeat(wideCount))
1185+
1186+
const referenceRoot = reference.children[0]
1187+
const wideRoot = wide.children[0]
1188+
if (referenceRoot.type !== 'list') throw new Error('expected list')
1189+
if (wideRoot.type !== 'list') throw new Error('expected list')
1190+
1191+
assert.equal(referenceRoot.spread, true, 'reference must be loose')
1192+
assert.equal(wideRoot.spread, referenceRoot.spread)
1193+
assert.equal(wideRoot.children.length, wideCount)
1194+
1195+
const referenceSpread = referenceRoot.children[0].spread
1196+
for (const item of [
1197+
wideRoot.children[0],
1198+
wideRoot.children[wideCount - 1]
1199+
]) {
1200+
assert.equal(item.spread, referenceSpread)
1201+
}
1202+
}
1203+
)
10921204
})
10931205

10941206
test('fixtures', async function (t) {

0 commit comments

Comments
 (0)