Skip to content

Commit e0aa037

Browse files
perf: combined Tier 1 rollup
Octopus merge of the three independent perf branches into one rollup so reviewers can evaluate the cumulative impact on a single bench run. Each underlying branch is also pushed on its own and can land independently. Branches merged: - perf/prepare-list-no-splice (Closes syntax-tree#49, Refs syntax-tree#50) - perf/dispatch-context-reuse - perf/stable-node-shape Cumulative impact, multi-run median-of-medians vs the baseline (spread in parentheses): 10,000 character entity references -43.8% (7.5%) 10,000 single-level list items -42.9% (11.6%, borderline) CommonMark spec * 35 (~564 KB) -13.8% (1.0%, very clean) full CommonMark spec (~16 KB) -12.3% (7.9%) CommonMark spec * 7 (~113 KB) -8.5% (1.0%, very clean) 'xxxx' x 10,000 (~40 KB) -4.0% (9.0%) Single-run full corpus shows wins on 20 of 27 inputs, ranging from -1% to -43%. The largest pathological wins are inputs heavy in character entity references (-42.2%), fenced code blocks (-40.7%), single-level list items (-37.0%), tabs (-35.6%), ATX headings (-28.2%), backtick code spans (-24.4%), inline links (-22.3%), inline images (-21.5%), HTML blocks (-15.3%), and one CommonMark example (-16.4%). Trade-offs: A 1 MB single paragraph reported +5.3% multi-run with a 9.4% spread, and +11.6% on a single full-corpus run. None of the three changes target the single-text-node path, so the small regression is the edge of that input's noise band. A 256 KB Unicode-heavy input reported +2.3% multi-run inside its 11.4% spread (treat as flat). A 10,000-unmatched-asterisk input moved +0.8% multi-run (flat). The pure emphasis stress inputs ('a**b' repeated 10,000 times and similar) reported +44% and +58% on a single run, but their cross-run spread is 44 to 53% on the baseline alone. The input shape (almost all attentionSequence events that mostly do not match a handler, no lists, no node-creation hot path) means none of the three optimizations can target what these inputs exercise. Treat the deltas as noise. 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.
4 parents f9ef1b3 + c4361a5 + 42ca832 + 007ba1e commit e0aa037

1 file changed

Lines changed: 162 additions & 34 deletions

File tree

dev/lib/index.js

Lines changed: 162 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ function compiler(options) {
212212
*/
213213
function compile(events) {
214214
/** @type {Root} */
215-
let tree = {type: 'root', children: []}
215+
let tree = {type: 'root', children: [], position: undefined}
216216
/** @type {Omit<CompileContext, 'sliceSerialize'>} */
217217
const context = {
218218
stack: [tree],
@@ -247,17 +247,25 @@ function compiler(options) {
247247

248248
index = -1
249249

250+
// The handler this-binding receives the same fields as `context` plus a
251+
// per-event `sliceSerialize`. Pre-allocate the merged object once outside
252+
// the loop and mutate sliceSerialize per event instead of allocating a
253+
// fresh merged object per event. Stack/tokenStack/data references stay
254+
// shared with `context`, so mutations inside handlers remain visible to
255+
// the post-loop code via `context.tokenStack`.
256+
const callContext = /** @type {CompileContext} */ (
257+
/** @type {unknown} */ (
258+
Object.assign({sliceSerialize: undefined}, context)
259+
)
260+
)
261+
250262
while (++index < events.length) {
251-
const handler = config[events[index][0]]
252-
253-
if (own.call(handler, events[index][1].type)) {
254-
handler[events[index][1].type].call(
255-
Object.assign(
256-
{sliceSerialize: events[index][2].sliceSerialize},
257-
context
258-
),
259-
events[index][1]
260-
)
263+
const event = events[index]
264+
const handler = config[event[0]]
265+
266+
if (own.call(handler, event[1].type)) {
267+
callContext.sliceSerialize = event[2].sliceSerialize
268+
handler[event[1].type].call(callContext, event[1])
261269
}
262270
}
263271

@@ -307,6 +315,14 @@ function compiler(options) {
307315
let firstBlankLineIndex
308316
/** @type {boolean | undefined} */
309317
let atMarker
318+
/**
319+
* Insertions accumulated in walk order: each {at, event} maps to an
320+
* `events.splice(at, 0, event)` in the original implementation. Applied
321+
* once at the end so a wide list's per-item O(n) splice cost collapses
322+
* to a single batched rebuild.
323+
* @type {Array<{at: number, event: Event}>}
324+
*/
325+
const insertions = []
310326

311327
while (++index <= length) {
312328
const event = events[index]
@@ -413,9 +429,10 @@ function compiler(options) {
413429
lineIndex ? events[lineIndex][1].start : event[1].end
414430
)
415431

416-
events.splice(lineIndex || index, 0, ['exit', listItem, event[2]])
417-
index++
418-
length++
432+
insertions.push({
433+
at: lineIndex || index,
434+
event: ['exit', listItem, event[2]]
435+
})
419436
}
420437

421438
// Create a new list item.
@@ -429,17 +446,100 @@ function compiler(options) {
429446
end: undefined
430447
}
431448
listItem = item
432-
events.splice(index, 0, ['enter', item, event[2]])
433-
index++
434-
length++
449+
insertions.push({at: index, event: ['enter', item, event[2]]})
435450
firstBlankLineIndex = undefined
436451
atMarker = true
437452
}
438453
}
439454
}
440455

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

445545
/**
@@ -1137,19 +1237,31 @@ function compiler(options) {
11371237
// Creaters.
11381238
//
11391239

1240+
// Each node factory pre-declares `position` as undefined as the trailing
1241+
// property. enter() patches it to a real value, but the property already
1242+
// exists, so V8 sees a single hidden class for each node type from
1243+
// construction through the rest of the pipeline. Without the pre-declare,
1244+
// each node took a hidden-class transition when enter() added position.
1245+
11401246
/** @returns {Blockquote} */
11411247
function blockQuote() {
1142-
return {type: 'blockquote', children: []}
1248+
return {type: 'blockquote', children: [], position: undefined}
11431249
}
11441250

11451251
/** @returns {Code} */
11461252
function codeFlow() {
1147-
return {type: 'code', lang: null, meta: null, value: ''}
1253+
return {
1254+
type: 'code',
1255+
lang: null,
1256+
meta: null,
1257+
value: '',
1258+
position: undefined
1259+
}
11481260
}
11491261

11501262
/** @returns {InlineCode} */
11511263
function codeText() {
1152-
return {type: 'inlineCode', value: ''}
1264+
return {type: 'inlineCode', value: '', position: undefined}
11531265
}
11541266

11551267
/** @returns {Definition} */
@@ -1159,13 +1271,14 @@ function compiler(options) {
11591271
identifier: '',
11601272
label: null,
11611273
title: null,
1162-
url: ''
1274+
url: '',
1275+
position: undefined
11631276
}
11641277
}
11651278

11661279
/** @returns {Emphasis} */
11671280
function emphasis() {
1168-
return {type: 'emphasis', children: []}
1281+
return {type: 'emphasis', children: [], position: undefined}
11691282
}
11701283

11711284
/** @returns {Heading} */
@@ -1174,28 +1287,41 @@ function compiler(options) {
11741287
type: 'heading',
11751288
// @ts-expect-error `depth` will be set later.
11761289
depth: 0,
1177-
children: []
1290+
children: [],
1291+
position: undefined
11781292
}
11791293
}
11801294

11811295
/** @returns {Break} */
11821296
function hardBreak() {
1183-
return {type: 'break'}
1297+
return {type: 'break', position: undefined}
11841298
}
11851299

11861300
/** @returns {Html} */
11871301
function html() {
1188-
return {type: 'html', value: ''}
1302+
return {type: 'html', value: '', position: undefined}
11891303
}
11901304

11911305
/** @returns {Image} */
11921306
function image() {
1193-
return {type: 'image', title: null, url: '', alt: null}
1307+
return {
1308+
type: 'image',
1309+
title: null,
1310+
url: '',
1311+
alt: null,
1312+
position: undefined
1313+
}
11941314
}
11951315

11961316
/** @returns {Link} */
11971317
function link() {
1198-
return {type: 'link', title: null, url: '', children: []}
1318+
return {
1319+
type: 'link',
1320+
title: null,
1321+
url: '',
1322+
children: [],
1323+
position: undefined
1324+
}
11991325
}
12001326

12011327
/**
@@ -1208,7 +1334,8 @@ function compiler(options) {
12081334
ordered: token.type === 'listOrdered',
12091335
start: null,
12101336
spread: token._spread,
1211-
children: []
1337+
children: [],
1338+
position: undefined
12121339
}
12131340
}
12141341

@@ -1221,28 +1348,29 @@ function compiler(options) {
12211348
type: 'listItem',
12221349
spread: token._spread,
12231350
checked: null,
1224-
children: []
1351+
children: [],
1352+
position: undefined
12251353
}
12261354
}
12271355

12281356
/** @returns {Paragraph} */
12291357
function paragraph() {
1230-
return {type: 'paragraph', children: []}
1358+
return {type: 'paragraph', children: [], position: undefined}
12311359
}
12321360

12331361
/** @returns {Strong} */
12341362
function strong() {
1235-
return {type: 'strong', children: []}
1363+
return {type: 'strong', children: [], position: undefined}
12361364
}
12371365

12381366
/** @returns {Text} */
12391367
function text() {
1240-
return {type: 'text', value: ''}
1368+
return {type: 'text', value: '', position: undefined}
12411369
}
12421370

12431371
/** @returns {ThematicBreak} */
12441372
function thematicBreak() {
1245-
return {type: 'thematicBreak'}
1373+
return {type: 'thematicBreak', position: undefined}
12461374
}
12471375
}
12481376

0 commit comments

Comments
 (0)