| id | 198 | |
|---|---|---|
| title | Fork goldmark with a per-parse arena for the four structural allocators | |
| status | ✅ | |
| model | opus | |
| depends-on |
|
|
| summary | Plan 197 PoC measured a 12.8 % allocs/op cut from one tactical change to goldmark's link-ref transformer (BlockReader reuse, the only tactical target in the top-5 hot allocators). The remaining four — NewTextSegment, NewParagraph, Segments.Append backing arrays, and FindClosure's NewSegments — are all structural: pointers escape to AST or back-array growth fires from inside the parser. A per-parse arena threaded through parser.Parser is the only shape that absorbs all four. Combined ceiling is ~41 % of corpus allocations. This plan vendors the goldmark subset, implements the arena, gates it with a build-tag A/B + equivalence harness, and ships behind `pkg/markdown`. |
Land a goldmark fork at pkg/goldmark/. Its
parser.Parser carries a per-parse arena. The arena
absorbs four structural allocators from
plan 197's matrix:
ast.NewTextSegment, ast.NewParagraph,
text.(*Segments).Append backing arrays, and
text.FindClosure's NewSegments. Combined ceiling
is ~41 % of allocations. Plan 197's
linkrefparagraph stays as a prior win.
Plan 197 shipped
the matrix's one tactical PoC: a per-parser
linkrefparagraph.Transformer. It reuses one
text.BlockReader across all paragraphs in a parse.
Measured savings on BenchmarkCheckCorpusLarge -benchtime=10x were 634 k → 553 k allocs/op
(−12.8 %), inside 6 % of the predicted 13.6 %. Wall
time dropped from 264 ms p95 to 247 ms.
The other four hot allocators are structural. They escape to the AST or grow per-block backing arrays. A pool-in-place will not work. Each allocation's lifetime is "until the AST consumer is done with the document". mdsmith consumes AST inside one Parse call (CLAUDE.md). That makes a per-parse arena the right shape.
The arena's API contract:
- One
arena.Arenalives on theparser.Parserfor the duration of oneParse(reader, opts...)call. - Allocators inside
pkg/goldmark/ast/andpkg/goldmark/text/route through the arena instead ofnew(T). Parsereturns;arena.Reset()is deferred so the slab is reusable on the next call.- AST node pointers returned from
Parseremain valid until the next call toParseon the same parser. mdsmith's consumers (rule packages, LSP server) already consume AST inside one Parse; this contract is documented inpkg/markdown.
Four stages.
Copy goldmark@v1.8.2 to pkg/goldmark/. Keep
the package layout (ast/, text/, parser/,
util/). Rewrite imports. Plan 197's
linkrefparagraph folds into the vendored parser/
as the default link-ref transformer. Keep upstream
tests at their original paths.
pkg/goldmark/arena/arena.go exposes a slab
allocator. Typed helpers: Text(), Paragraph(),
Segments(cap). Reset() discards live pointers
and resets cursors. Constructors in vendored ast/
and text/ accept a nil-safe *arena.Arena. The
parser.Parser carries one arena and defers Reset.
pkg/goldmark/equivalence_test.go runs every
upstream test through the fork. It diffs AST shape
and rendered HTML. The harness gates every later
arena change.
Re-run BenchmarkCheckCorpusLarge -benchtime=10x
with the arena landed. Expected target: ≥ 35 % cut
from the post-plan-197 baseline (553 k → ≤ 360 k
allocs/op). Wall time ≤ post-plan-197 baseline.
A build-tag A/B (-tags goldmark_upstream) lets CI
diff the two paths on the same source until the fork
is the only path.
- Vendor goldmark@v1.8.2 under
pkg/goldmark/. Rewrite imports.go build ./...andgo test ./pkg/goldmark/...stay green with the fork as a drop-in. (Done in the current PR — pkg/goldmark/ is wired viareplace github.com/yuin/goldmark => ./pkg/goldmarkin the root go.mod. Because pkg/goldmark is a nested module, fork-specific tests run via an explicitgo test ./...inside pkg/goldmark/ — see the CI workflow.) (Superseded after v0.40.0: the replace directive brokego install m@version, which rejects modules that carry one. The fork is now part of the main module, imported asgithub.com/jeduden/mdsmith/pkg/goldmark/…; the roottestjob covers its default path and thegoldmark-fork-testjob keeps thegoldmark_upstreamA/B axis.) - Move plan 197's
linkrefparagraphinto the vendoredparser/package as the default link-ref transformer. Delete the old standalone package. (Done in the current PR — the transformer is at pkg/goldmark/parser/link_ref.go with the BlockReader-reuse + Reset semantics, and the standalone linkrefparagraph package is removed.) - Add
pkg/goldmark/arena/with the typed slab allocator. Reset is idempotent. (Slab pools for*ast.Text,*ast.Paragraph,*text.Segments, and a per-Segment backing pool; nil-safe; unit tests inpkg/goldmark/arena/arena_test.go.) - Thread the arena through
ast.NewText,ast.NewParagraph,text.NewSegments, andtext.(*Segments).Append's backing array allocation. (Done viatext.SegmentsGrowerinterface andtext.Segments.SetBackingso Append/Unshift/AppendAll consult the arena when the current backing is full.text.Readerandtext.BlockReadergainedSetSegmentsCreatorto route FindClosure'sNewSegments. Theast.MergeOr{Append,Replace}TextSegmentAvariants accept anast.TextAllocator(an interface satisfied by*arena.Arena).) - Wire
parser.Parserto own the arena and defer Reset onParsereturn. Refactored to a per-Parse arena rather than per-Parser to sidestep the risk-section hazard (mdsmith's parser pool reuses parsers across documents, so a shared arena would clobber a still-live AST on the next Parse). The slabs are garbage-collected with the returned AST; the per-node allocation savings still land because one slab absorbs many nodes.Context.Arena()surfaces the per-Parse arena to block/inline parsers. - Add the equivalence harness — every upstream
goldmark test runs against the forked parser and
diffs AST + HTML. Implemented as
pkg/goldmark/equivalence_test.go: runs a structured corpus through two configuredgoldmark.Markdownstacks (arena andparser.WithNoArena()) and diffs both the rendered HTML and an AST structural summary. IncludesTestEquivalence_ReuseParserSurvivesPriorASTthat explicitly parses two documents through the same pooled parser and asserts the first AST is still readable after the second Parse. - Add the build-tag A/B path so CI can lint the
same source through both.
goldmark_upstreamtag togglesnewArenaForParse(arena_on.go / arena_off.go).parser.WithNoArena()provides the runtime opt-out for in-process diffs. CI'sgoldmark-fork-testjob now runs the fork's tests under both tags. - Re-run
BenchmarkCheckCorpusLargeand record results in this plan. Median allocs/op dropped from 553 k (post-plan-197) to 255 k — a 54 % cut, well past the ≥ 35 % target. p95 wall time 237 ms, under the 247 ms post-plan-197 baseline. Measured in this PR's container:Intel(R) Xeon(R) @ 2.80GHz, 4 cores, Linux 6.18.5, Go 1.25.8,go test -run=^$ -bench=BenchmarkCheckCorpusLarge -benchtime=10x ./internal/engine/. The non-arena (goldmark_upstream tag) result on the same hardware is 511 k allocs/op / 254 ms p95. - Update docs/development/index.md
to point at the fork as the canonical parser.
Project Layout now lists
pkg/goldmark/as the vendored fork; the fork's perf changes plus the WithNoArena andgoldmark_upstreamA/B paths are documented in markdown-library.md since CLAUDE.md's inlined include of index.md is at the 300-line cap and a new subsection would push it over.
The arena couples AST lifetime to the next Parse
call on the same parser. mdsmith's parserPool in
pkg/markdown/parser.go
returns parsers between parses, so two consecutive
calls on the same goroutine may share a pool slot
and the second Reset invalidates the first AST.
Mitigation: an audit pass over every consumer, and
an opt-in parser.WithNoArena() for callers that
need long-lived AST.
The fork diverges from upstream.
Mitigation: the equivalence harness gates every
change. A quarterly upstream-merge task keeps drift
visible. This task lives in plan/ alongside this
plan rather than in docs/development/secret-rotations.md
— that file is scoped to credential rotation only and
is not the right home for fork-maintenance cadence.
-
pkg/goldmark/is the canonical parser andpkg/markdownimports only from it. (Wired in plan 197 via the rootgo.modreplace; plan 198 added the arena/grower surface inside the fork without changing the import path.) -
BenchmarkCheckCorpusLarge -benchtime=10xmedian allocs/op ≤ 360 k (≥ 35 % cut from 553 k post-plan-197 baseline). Measured: 254 704 allocs/op (54 % cut). -
BenchmarkCheckCorpusLargep95 wall time ≤ 247 ms (post-plan-197 baseline). Measured: 237 ms. - Equivalence harness passes — every upstream goldmark test runs through the fork with identical AST + HTML.
-
go test ./...andgo test -race ./...green. -
mdsmith check .green. -
go tool golangci-lint runreports no issues.