|
| 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. |
0 commit comments