Skip to content

Commit 0420c86

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 and use a chunked spread so the splice never hits V8's argument count limit. 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 a chunked spread. 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 the rebuild splits the new sub-array into chunks once it exceeds a safe size. The chunk 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, which tracks the same V8 constraint. 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 -36.3% (4.0%) 5,000 ATX headings -22.9% (5.5%) one CommonMark example -13.2% (16.0%) full CommonMark spec (~16 KB) -13.0% (46.4%, NOISY) CommonMark spec * 35 (~564 KB) -10.7% (0.8%, very clean) 256 nested ordered list levels -4.0% (6.7%) CommonMark spec * 7 (~113 KB) -3.8% (12.8%) 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 +13% and +28% on a single run, 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 1450/1450, 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 0420c86

2 files changed

Lines changed: 133 additions & 7 deletions

File tree

dev/lib/index.js

Lines changed: 114 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,115 @@ 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; otherwise empty the range with one splice and refill it
523+
// in chunks so no individual call hits the limit. The chunk size
524+
// matches the threshold micromark-util-chunked uses for its own
525+
// splice helper, which tracks the same V8 constraint.
526+
const SAFE_SPREAD = 10_000
527+
if (replacement.length <= SAFE_SPREAD) {
528+
events.splice(start, rangeLength, ...replacement)
529+
} else {
530+
events.splice(start, rangeLength)
531+
let chunkStart = 0
532+
while (chunkStart < replacement.length) {
533+
const chunkEnd = Math.min(
534+
chunkStart + SAFE_SPREAD,
535+
replacement.length
536+
)
537+
events.splice(
538+
start + chunkStart,
539+
0,
540+
...replacement.slice(chunkStart, chunkEnd)
541+
)
542+
chunkStart = chunkEnd
543+
}
544+
}
545+
}
546+
}
547+
441548
events[start][1]._spread = listSpread
442-
return length
549+
return length + insertions.length
443550
}
444551

445552
/**

test/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,25 @@ 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 a chunked spread
1097+
// once the replacement exceeds V8's safe argument count. ~1000
1098+
// single-line items produce a replacement of ~16k entries, which
1099+
// crosses the threshold and exercises the chunked branch.
1100+
const itemCount = 1000
1101+
const markdown = '- a\n'.repeat(itemCount)
1102+
const tree = fromMarkdown(markdown)
1103+
assert.equal(tree.children.length, 1)
1104+
const list = tree.children[0]
1105+
assert.equal(list.type, 'list')
1106+
assert.equal(list.children.length, itemCount)
1107+
assert.equal(list.children[0].type, 'listItem')
1108+
assert.equal(list.children[itemCount - 1].type, 'listItem')
1109+
}
1110+
)
10921111
})
10931112

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

0 commit comments

Comments
 (0)