perf: pre-declare position on delete factory#4
Merged
Conversation
Problem: the strikethrough handler creates each delete node as a fresh object literal, then mdast-util-from-markdown's enter() sets node.position afterwards. V8 treats that late assignment as a shape change, so every delete node walks through two hidden classes instead of one. Core nodes already avoid this by declaring position up front; without the same trick on extension nodes, GFM trees end up mixing two layouts and downstream tree walks lose monomorphic dispatch. Goal: declare position on the delete literal at construction so V8 keeps a single hidden class for delete from start to finish. Changes: - lib/index.js: trailing position: undefined on the delete literal in enterStrikethrough. Notes: pairs with the matching core change in syntax-tree/mdast-util-from-markdown#53; merged on its own this branch is a no-op. Validated with npm test (build, format, 100% coverage, 13 of 13 tests in dev and prod). Bench (local harness, 3-run median-of-medians, GFM tokenizer and mdast extensions stacked). Numbers below are the parse time delta versus an unmodified upstream main; positive numbers mean slower, negative numbers mean faster. Input: one paragraph of 1000 ~~strike~~ runs. On the baseline this scenario carries the second-largest mdast-layer share in the GFM corpus (full 11.0 ms, tokenize-only 5.2 ms; the mdast layer is the gap between those two). Effect of pairing this branch with PR #53: - PR #53 alone, extensions on upstream main: 14.6 percent slower. Trees mix two layouts because delete nodes still take a shape change when position is patched in, so downstream tree-walk call sites go polymorphic. - PR #53 with this branch alongside (and the three sibling extension branches): 2.6 percent slower, which sits inside the bench's drift floor of 4 to 12 percent (main vs main with no code change in the same window). The paired configuration recovers about 12 percentage points relative to PR #53 on its own. Without this branch, PR #53 would appear to regress strikethrough-heavy parses; with it, the parse runs roughly as fast as upstream main does today.
ChristianMurphy
force-pushed
the
perf/stable-node-shape
branch
from
May 4, 2026 21:14
a058694 to
efdc7da
Compare
wooorm
approved these changes
Jun 3, 2026
This comment has been minimized.
This comment has been minimized.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Initial checklist
Description of changes
Problem: the strikethrough handler creates each delete node as a
fresh object literal, then mdast-util-from-markdown's enter() sets
node.position afterwards. V8 treats that late assignment as a shape
change, so every delete node walks through two hidden classes instead
of one. Core nodes already avoid this by declaring position up front;
without the same trick on extension nodes, GFM trees end up mixing
two layouts and downstream tree walks lose monomorphic dispatch.
Goal: declare position on the delete literal at construction so V8
keeps a single hidden class for delete from start to finish.
Changes:
in enterStrikethrough.
Notes: pairs with the matching core change in
syntax-tree/mdast-util-from-markdown#53;
merged on its own this branch is a no-op. Validated with npm test
(build, format, 100% coverage, 13 of 13 tests in dev and prod).
Bench (local harness, 3-run median-of-medians, GFM tokenizer and
mdast extensions stacked). Numbers below are the parse time delta
versus an unmodified upstream main; positive numbers mean slower,
negative numbers mean faster.
Input: one paragraph of 1000
strikeruns. On the baseline thisscenario carries the second-largest mdast-layer share in the GFM
corpus (full 11.0 ms, tokenize-only 5.2 ms; the mdast layer is the
gap between those two).
Effect of pairing this branch with PR #53:
Trees mix two layouts because delete nodes still take a shape
change when position is patched in, so downstream tree-walk call
sites go polymorphic.
branches): 2.6 percent slower, which sits inside the bench's drift
floor of 4 to 12 percent (main vs main with no code change in the
same window).
The paired configuration recovers about 12 percentage points relative
to PR #53 on its own. Without this branch, PR #53 would appear to
regress strikethrough-heavy parses; with it, the parse runs roughly
as fast as upstream main does today.