Skip to content

Commit c4361a5

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 unsplice'd 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 and sort 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. Inputs that benefit, with multi-run median-of-medians vs the baseline (spread in parentheses): 10,000 single-level list items -38.0% (7.9%) 5,000 ATX headings -18.3% (3.8%) one CommonMark example -8.3% (7.5%) CommonMark spec * 35 (~564 KB) -8.0% (2.6%) full CommonMark spec (~16 KB) -7.2% (11.3%) CommonMark spec * 7 (~113 KB) -2.5% (3.2%) 256 nested ordered list levels -2.4% (46.5% spread, treat as flat on this stack) 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 1448/1448, 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 c4361a5

1 file changed

Lines changed: 99 additions & 7 deletions

File tree

dev/lib/index.js

Lines changed: 99 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,100 @@ 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+
// newSub array. Wide lists go through the batched rebuild and use a
454+
// 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, one sort, and one splice replacing the
459+
// whole range; total cost is O(N + K) plus a fixed allocation and
460+
// sort overhead. Below some K the rebuild's constant overhead
461+
// dominates the per-insertion splice work; above some K the fast
462+
// path's K * suffix dominates the rebuild's bounded cost. The
463+
// crossover varies by document shape, so this threshold is a
464+
// balance between deeply nested lists (which favor staying on the
465+
// fast path) and documents whose lists outgrow the constants
466+
// (which favor the rebuild). The chosen value was confirmed by
467+
// sweeping the threshold across representative inputs.
468+
const SMALL_LIST_LIMIT = 8
469+
if (insertions.length <= SMALL_LIST_LIMIT) {
470+
// Splice from latest `at` to earliest so unsplice'd positions stay
471+
// valid. Same-`at` groups are reversed compared to walk order, which
472+
// produces the same final ordering the original splice loop did
473+
// (exit before enter at the same insertion point).
474+
let insertion = insertions.length
475+
while (insertion-- > 0) {
476+
events.splice(
477+
insertions[insertion].at,
478+
0,
479+
insertions[insertion].event
480+
)
481+
}
482+
} else {
483+
insertions.sort((a, b) => a.at - b.at)
484+
const rangeLength = length - start + 1
485+
/** @type {Array<Event>} */
486+
const replacement = Array.from({
487+
length: rangeLength + insertions.length
488+
})
489+
let writeIndex = 0
490+
let insertionIndex = 0
491+
let sourceIndex = start
492+
while (sourceIndex <= length) {
493+
while (
494+
insertionIndex < insertions.length &&
495+
insertions[insertionIndex].at === sourceIndex
496+
) {
497+
replacement[writeIndex++] = insertions[insertionIndex].event
498+
insertionIndex++
499+
}
500+
501+
replacement[writeIndex++] = events[sourceIndex++]
502+
}
503+
504+
// V8 limits the number of arguments that can be spread into a
505+
// function call before triggering a stack overflow. Splice into a
506+
// single call when the new sub-array fits comfortably below that
507+
// limit; otherwise empty the range with one splice and refill it
508+
// in chunks so no individual call hits the limit. The chunk size
509+
// matches the threshold micromark-util-chunked uses for its own
510+
// splice helper, which tracks the same V8 constraint.
511+
const SAFE_SPREAD = 10_000
512+
if (replacement.length <= SAFE_SPREAD) {
513+
events.splice(start, rangeLength, ...replacement)
514+
} else {
515+
events.splice(start, rangeLength)
516+
let chunkStart = 0
517+
while (chunkStart < replacement.length) {
518+
const chunkEnd = Math.min(
519+
chunkStart + SAFE_SPREAD,
520+
replacement.length
521+
)
522+
events.splice(
523+
start + chunkStart,
524+
0,
525+
...replacement.slice(chunkStart, chunkEnd)
526+
)
527+
chunkStart = chunkEnd
528+
}
529+
}
530+
}
531+
}
532+
441533
events[start][1]._spread = listSpread
442-
return length
534+
return length + insertions.length
443535
}
444536

445537
/**

0 commit comments

Comments
 (0)