Skip to content

Commit aed18aa

Browse files
author
merge-queue-bot
committed
Merge PR #614: perf(goldmark): arena Heading/ListItem + research the parity-vs-gomarklint gap
2 parents 16ea3bc + ceaa6f2 commit aed18aa

8 files changed

Lines changed: 365 additions & 4 deletions

File tree

docs/research/benchmarks/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ lacks, so those tools may still do marginally more in this
177177
mode. Read `mdsmith-parity` as a conservative upper bound on
178178
mdsmith's same-rules speed, not a byte-identical rule set.
179179

180+
**Why parity trails gomarklint specifically.** gomarklint is
181+
the fastest tool in the table because it never builds an AST —
182+
it is a pure line scanner. mdsmith-parity cannot follow it
183+
there: 27 of parity's 30 rules require the parsed CommonMark
184+
tree, so the goldmark parse (~35% of parity's wall time) is
185+
forced. [gomarklint architecture and the parity
186+
gap](gomarklint-architecture.md) reviews gomarklint's design,
187+
breaks the parity profile down bucket by bucket, and records
188+
the optimization levers and their ceilings.
189+
180190
**The gate + profiler loop caught two real bugs.** The
181191
first run had mdsmith at ~1.0 s on the repo corpus but
182192
~1.6 s on the *smaller* 234-file neutral corpus — slower on
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
---
2+
summary: >-
3+
How to beat gomarklint in mdsmith's parity config on benchmark 2.
4+
Reviews gomarklint's line-scan architecture, measures every
5+
optimization lever (arena, PGO, GC — all rejected with numbers),
6+
shows that even a free parse leaves parity above gomarklint, and
7+
scopes the one design that reaches the goal: a parity line-scan
8+
pipeline that skips the CommonMark AST entirely.
9+
---
10+
# gomarklint architecture and the parity gap
11+
12+
This page answers a single question: on benchmark 2 (the neutral
13+
corpus — 234 Rust Book + Reference files), why does
14+
`mdsmith-parity` run at roughly 1.8x gomarklint's wall time, and
15+
what can close that gap?
16+
17+
It is a research note, not a tuning changelog. The headline
18+
finding is architectural and does not move with micro-optimization:
19+
**gomarklint never parses Markdown, and 27 of parity's 30 rules
20+
force mdsmith to.**
21+
22+
## gomarklint in one paragraph
23+
24+
gomarklint (`shinagawa-web/gomarklint`, v3.2.3 — the pinned
25+
benchmark binary) is a line scanner. `collectErrors` strips front
26+
matter, runs `lines := strings.Split(body, "\n")` once, and hands
27+
that `[]string` to every rule. Each rule is a plain function with
28+
the shape:
29+
30+
```go
31+
func CheckMaxLineLength(path string, lines []string, offset int, ...) []LintError
32+
```
33+
34+
There is no CommonMark parse, no AST, and no node tree — ever.
35+
Fenced-code state, heading levels, and list markers are tracked by
36+
walking the lines with byte comparisons. A cheap prefilter,
37+
`firstNonSpaceByte`, finds the first non-space byte of a line so
38+
`strings.TrimSpace` only runs on lines that could match a rule.
39+
Rules reach for `strings.HasPrefix` / `bytes.IndexByte` /
40+
direct byte indexing rather than `regexp` in their hot paths.
41+
42+
Concurrency is one goroutine per file (`go func(p string)` in a
43+
loop over the deduped path set), with a single mutex guarding result
44+
aggregation. The external-link checker — the one rule that would
45+
dominate — is off by default, so the default run is pure in-process
46+
line scanning. There is no on-disk cache, which is why the benchmark
47+
gives gomarklint no `--no-cache` flag.
48+
49+
That is the entire performance story: split into lines once, scan
50+
the lines with byte ops, fan out per file. It is fast because it does
51+
structurally less than any AST linter can.
52+
53+
## The measured difference
54+
55+
Wall-clock medians on the real 234-file neutral corpus. The
56+
absolute numbers below are from a 4-core dev box and run higher than
57+
the published page (different hardware); the **ratios and the
58+
profile percentages are what transfer**, and they match the
59+
published `gomarklint 18 ms / parity 31 ms / full 81 ms`.
60+
61+
| Run | median | vs gomarklint |
62+
| -------------------------------------- | ------- | ------------- |
63+
| gomarklint | ~40 ms | 1.0x |
64+
| mdsmith-parity (`-c parity`) | ~74 ms | ~1.8x |
65+
| mdsmith default | ~105 ms | ~2.6x |
66+
| mdsmith repo-config (published "full") | ~170 ms | ~4.0x |
67+
68+
CPU profile of the **parity** run (the apples-to-apples comparison),
69+
share of total samples:
70+
71+
| Bucket | share | what it is |
72+
| --------------------- | --------- | ------------------------------------- |
73+
| `goldmark` parse | ~35% | block + inline CommonMark parse |
74+
| rules | ~36% | the 30 enabled structural rules |
75+
| read + per-file setup | ~10% | file I/O, front matter, FS, gitignore |
76+
| merge / sort / walk | remainder | result assembly, workspace walk |
77+
78+
The single biggest cost in the parity run is the parse, and
79+
gomarklint pays none of it. Within the parse, block parsing
80+
(`parseBlocks` → `openBlocks`/`closeBlocks`) is ~23% and inline
81+
parsing (`walkBlock`) is ~11%. Individual rules are each cheap —
82+
the costliest, `atx-heading-whitespace` (MDS064), is ~7%, and most
83+
of that is the shared code-block-line walk it happens to trigger
84+
first, not the rule's own line scan.
85+
86+
## Why parity cannot skip the parse
87+
88+
The obvious idea — parse lazily, and skip goldmark entirely for the
89+
cheap structural rules the way gomarklint does — does not help the
90+
parity config. **27 of parity's 30 active rules require the AST.**
91+
Only three are pure line scanners (`single-trailing-newline`,
92+
`unique-frontmatter`, `no-trailing-punctuation-in-heading`).
93+
94+
The other 27 either implement `rule.NodeChecker` (driven by the
95+
shared AST walk) or read `f.AST` / link references / code-block line
96+
sets directly: `line-length` skips fenced code via the AST,
97+
`no-bare-urls` and `link-validity` need parsed links,
98+
`no-unused-link-definitions` and `no-undefined-reference-labels`
99+
need goldmark's link-reference map, `list-marker-space` and
100+
`blockquote-whitespace` walk nodes, and so on. A lazy AST is built
101+
the moment any one of them runs — and in parity, they all run.
102+
103+
So the parse is not incidental overhead that better engineering can
104+
remove. It is load-bearing for the rules parity keeps, and it is the
105+
foundation for everything mdsmith does that gomarklint cannot:
106+
cross-file link integrity, generated sections, schemas, rename, and
107+
markdown-as-data. The ~35% parse cost is the architectural price of
108+
that model.
109+
110+
### A note on the "full" benchmark number
111+
112+
The published `mdsmith = 81 ms` is partly a methodology artifact,
113+
not a pure measure of mdsmith's defaults. The harness invokes
114+
`mdsmith check $corpus` from the repository root, so config discovery
115+
walks up and finds mdsmith's own `.mdsmith.yml` and applies it to the
116+
neutral corpus — including the opt-in, Punkt-segmenter-heavy MDS024
117+
`paragraph-structure`, which mdsmith's defaults leave **off**
118+
precisely because the trained sentence tokenizer costs ~20% of wall
119+
time on prose. Every other tool in the comparison runs with its own
120+
defaults. A defaults-vs-defaults run drops mdsmith's number
121+
substantially (~81 → ~50 ms estimated) with no code change. This is
122+
a fairness gap in the comparison, not a regression in mdsmith;
123+
`mdsmith-parity` already sidesteps it by selecting an explicit config.
124+
125+
## Goal: beat gomarklint in the parity config
126+
127+
The target is to make `mdsmith check -c parity` finish benchmark 2
128+
in less wall time than gomarklint. This section records every lever
129+
tried with its measured effect, then the one design the numbers leave
130+
standing.
131+
132+
### What does not get there (measured, not guessed)
133+
134+
Three "free" levers were measured on the real corpus and rejected:
135+
136+
- **Allocation / arena.** The per-parse slab arena already absorbs
137+
Text, Paragraph, Segments, CodeSpan, Link, Emphasis. Extending it
138+
to Heading and ListItem (shipped in this PR) removes ~8.2k heap
139+
objects per run — they vanish from the allocation profile and the
140+
equivalence gate stays green — but **wall time moved within noise.**
141+
- **PGO.** A profile-guided rebuild of `cmd/mdsmith` (the shipped
142+
binary is already PGO'd) left parity flat to ~1%.
143+
- **GC tuning.** `GOGC=off` and `GOGC=800` left parity flat. The
144+
~16% GC seen in the in-process bench is an artifact of running 60
145+
iterations back to back; the real single-shot CLI barely collects
146+
before it exits.
147+
148+
The lesson is decisive: **parity's wall time is parse + rule
149+
computation, not allocation or GC.** Micro-optimization does not
150+
reach gomarklint.
151+
152+
### Why even a free parse is not enough
153+
154+
Break parity's wall time into buckets (CPU profile shares):
155+
parse ~38%, rules ~42%, per-file + walk + output ~19%. So even if the
156+
goldmark parse cost dropped to **zero**, parity would still spend
157+
rules + overhead ≈ 60% of its current time — roughly 48 ms against
158+
gomarklint's ~44 ms. And the parse cannot drop to zero by tuning:
159+
goldmark is the fastest pure-Go CommonMark parser, and mado (Rust,
160+
which *also* parses) lands at ~29 ms next to parity's ~31 ms — every
161+
parsing linter clusters together. gomarklint's ~18 ms is an outlier
162+
for exactly one reason: it never builds a tree.
163+
164+
Conclusion: beating gomarklint requires doing what gomarklint does —
165+
**not building the AST for the parity rule set** — *and* trimming the
166+
rule/overhead cost below a line scanner's. Nothing short of that
167+
clears the bar.
168+
169+
## The path that reaches the goal: a parity line-scan pipeline
170+
171+
This is the only design the arithmetic leaves, and it is a real
172+
multi-PR project, not a single-session tweak. It is gomarklint's
173+
architecture, applied to the rules parity keeps.
174+
175+
1. **Line-scan structural model.** A single-pass scanner over
176+
`f.Lines` that yields what the block-structure rules consume:
177+
per-line class (heading / fence / list / blockquote / blank /
178+
HTML / paragraph), code-fence line ranges, and front-matter
179+
bounds — gomarklint's fence-and-heading tracking, exposed on the
180+
`*lint.File`.
181+
2. **Line-scan inline model.** A byte scanner for the six
182+
inline-dependent parity rules — links and autolinks
183+
(`no-bare-urls`, `link-validity`), images (`no-empty-alt-text`),
184+
reference definitions and uses (`no-unused-link-definitions`,
185+
`no-undefined-reference-labels`), and whole-paragraph emphasis
186+
(`no-emphasis-as-heading`) — so no parity rule needs the inline
187+
AST.
188+
3. **`LineRule` capability + parse skip.** A rule interface that
189+
consumes the line-scan models, and an engine gate: when every
190+
enabled rule is line-capable (parity, and many default-style
191+
configs), skip `NewFileFromSourcePooled` entirely. This is where
192+
the ~38% parse cost actually disappears.
193+
4. **Equivalence harness.** Diff every converted rule's output
194+
(line-scan vs current AST path) across the corpus and the rule
195+
fixtures, the same way the arena change is gated against the
196+
non-arena renderer — CommonMark block edge cases are the risk, and
197+
this is how it is contained.
198+
199+
**Acceptance:** `mdsmith check -c parity` beats gomarklint on
200+
benchmark 2; every existing rule fixture passes; the line-scan vs
201+
AST equivalence gate is green.
202+
203+
**Cost and risk, stated plainly.** Twenty-one block-structure rules
204+
and six inline rules move onto the line-scan models — a large surface
205+
with two code paths per rule to maintain, and real CommonMark
206+
edge-case exposure (lazy continuation, setext headings, nested
207+
fences, reference-label folding). Stage 1 (the structural scanner) is
208+
the natural first increment and is independently testable; it yields
209+
benchmark movement only once stage 3 lets the engine skip the parse.
210+
211+
## Where this PR leaves it
212+
213+
The arena extension shipped here is the safe down payment: a correct,
214+
equivalence-gated allocation win that reduces GC pressure under the
215+
file pool. It does **not** close the wall-time gap — by the
216+
measurements above, nothing at the allocation or GC layer can. The
217+
route to actually beating gomarklint is the line-scan pipeline above,
218+
scoped as staged work with a hard, measurable acceptance bar.

pkg/goldmark/arena/arena.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import (
4343
const (
4444
textSlabCap = 256
4545
paragraphSlabCap = 32
46+
headingSlabCap = 32
47+
listItemSlabCap = 64
4648
segmentsObjSlabCap = 64
4749
segmentSlabCap = 1024
4850
codeSpanSlabCap = 64
@@ -63,6 +65,8 @@ const (
6365
type Arena struct {
6466
texts slabs[ast.Text]
6567
paragraphs slabs[ast.Paragraph]
68+
headings slabs[ast.Heading]
69+
listItems slabs[ast.ListItem]
6670
segmentsObjs slabs[text.Segments]
6771
segments []*segmentSlab
6872
codeSpans slabs[ast.CodeSpan]
@@ -154,6 +158,8 @@ func (a *Arena) Reset() {
154158
// reset.
155159
a.texts.reset()
156160
a.paragraphs.reset()
161+
a.headings.reset()
162+
a.listItems.reset()
157163
a.segmentsObjs.reset()
158164
a.codeSpans.reset()
159165
a.links.reset()
@@ -209,6 +215,34 @@ func (a *Arena) Paragraph() *ast.Paragraph {
209215
return p
210216
}
211217

218+
// Heading returns a zero-initialised *ast.Heading with the given
219+
// level from the arena. The block parsers (atx_heading, setext_headings)
220+
// build every heading through this so the heading-dense neutral corpus
221+
// no longer heap-allocates one ast.Heading per heading. With a nil
222+
// receiver falls back to ast.NewHeading.
223+
func (a *Arena) Heading(level int) *ast.Heading {
224+
if a == nil {
225+
return ast.NewHeading(level)
226+
}
227+
h := a.headings.alloc(headingSlabCap)
228+
h.Level = level
229+
return h
230+
}
231+
232+
// ListItem returns a zero-initialised *ast.ListItem with the given
233+
// offset from the arena. list_item's parser builds every item through
234+
// this so list-dense documents no longer heap-allocate one
235+
// ast.ListItem per item. With a nil receiver falls back to
236+
// ast.NewListItem.
237+
func (a *Arena) ListItem(offset int) *ast.ListItem {
238+
if a == nil {
239+
return ast.NewListItem(offset)
240+
}
241+
li := a.listItems.alloc(listItemSlabCap)
242+
li.Offset = offset
243+
return li
244+
}
245+
212246
// RawHTML returns a *ast.RawHTML whose inline Segments is
213247
// arena-backed. The RawHTML struct itself is heap-allocated (the
214248
// arena does not slab it — it sees too few uses relative to Text
@@ -348,3 +382,21 @@ func (a *Arena) TextsAllocated() int {
348382
}
349383
return a.texts.used()
350384
}
385+
386+
// HeadingsAllocated reports how many Heading nodes have been carved
387+
// from the arena since the last Reset. Nil-safe like TextsAllocated.
388+
func (a *Arena) HeadingsAllocated() int {
389+
if a == nil {
390+
return 0
391+
}
392+
return a.headings.used()
393+
}
394+
395+
// ListItemsAllocated reports how many ListItem nodes have been carved
396+
// from the arena since the last Reset. Nil-safe like TextsAllocated.
397+
func (a *Arena) ListItemsAllocated() int {
398+
if a == nil {
399+
return 0
400+
}
401+
return a.listItems.used()
402+
}

pkg/goldmark/arena/arena_internal_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,66 @@ func TestStructuralSlabsAdvanceCursors(t *testing.T) {
132132
}
133133
}
134134

135+
// TestStructuralHeadingListItemSlabs pins the Heading/ListItem arena
136+
// constructors. The heading- and list-heavy neutral corpus (Rust Book
137+
// + Reference) allocated every ast.Heading and ast.ListItem on the
138+
// heap before this; routing them through the arena drops those from
139+
// the per-file allocation count like the Text/Paragraph slabs already
140+
// do. The constructors must match the upstream ones field-for-field,
141+
// fall back to the heap on a nil receiver, count their allocations,
142+
// and reuse their slabs across Reset.
143+
func TestStructuralHeadingListItemSlabs(t *testing.T) {
144+
var nilA *Arena
145+
if nilA.Heading(2) == nil || nilA.ListItem(3) == nil {
146+
t.Fatal("nil arena constructors must fall back to heap nodes")
147+
}
148+
if nilA.HeadingsAllocated() != 0 || nilA.ListItemsAllocated() != 0 {
149+
t.Fatal("nil arena must report zero")
150+
}
151+
152+
a := New()
153+
if got, want := a.Heading(3).Level, ast.NewHeading(3).Level; got != want {
154+
t.Errorf("Heading level = %d, want %d", got, want)
155+
}
156+
if got, want := a.ListItem(5).Offset, ast.NewListItem(5).Offset; got != want {
157+
t.Errorf("ListItem offset = %d, want %d", got, want)
158+
}
159+
if got, want := a.Heading(1).Kind(), ast.NewHeading(1).Kind(); got != want {
160+
t.Errorf("Heading kind = %v, want %v", got, want)
161+
}
162+
if got, want := a.ListItem(1).Kind(), ast.NewListItem(1).Kind(); got != want {
163+
t.Errorf("ListItem kind = %v, want %v", got, want)
164+
}
165+
166+
// Overfill both slabs so the cursor-advance path runs, then verify
167+
// Reset rewinds the counts and reuses the slabs run after run.
168+
fill := func() {
169+
for i := 0; i < headingSlabCap+1; i++ {
170+
a.Heading(1)
171+
}
172+
for i := 0; i < listItemSlabCap+1; i++ {
173+
a.ListItem(0)
174+
}
175+
}
176+
a.Reset()
177+
fill()
178+
hs, ls := len(a.headings.list), len(a.listItems.list)
179+
if hs < 2 || ls < 2 {
180+
t.Fatalf("expected at least 2 slabs each, got %d %d", hs, ls)
181+
}
182+
for cycle := 0; cycle < 3; cycle++ {
183+
a.Reset()
184+
if a.HeadingsAllocated() != 0 || a.ListItemsAllocated() != 0 {
185+
t.Fatalf("cycle %d: Reset must rewind allocation counts to zero", cycle)
186+
}
187+
fill()
188+
if len(a.headings.list) != hs || len(a.listItems.list) != ls {
189+
t.Fatalf("cycle %d: slab counts grew: %d %d",
190+
cycle, len(a.headings.list), len(a.listItems.list))
191+
}
192+
}
193+
}
194+
135195
// TestTextsAllocatedCounts pins the introspection helper both ways:
136196
// nil arena reports zero, and counts track allocations across Reset.
137197
func TestTextsAllocatedCounts(t *testing.T) {

0 commit comments

Comments
 (0)