Skip to content

perf(parser): reduce allocations in the parse hot path - #477

Open
Pastalikek65 wants to merge 1 commit into
alecthomas:masterfrom
Pastalikek65:fix-103-performance
Open

perf(parser): reduce allocations in the parse hot path#477
Pastalikek65 wants to merge 1 commit into
alecthomas:masterfrom
Pastalikek65:fix-103-performance

Conversation

@Pastalikek65

Copy link
Copy Markdown

Reduces allocations in the parser hot path.

Results (microc benchmark)

metric before after delta
allocs/op 59694 55054 -7.8%
B/op 4572762 4161671 -9.0%

ns/op was noisy on the test machine; the allocation/byte reductions are stable and directly reduce GC pressure, which is the dominant cost on this benchmark's CPU profile (mallocgc ≈ 27% of samples).

Changes

  1. apply []contextFieldSet by value — previously []*contextFieldSet, which heap-allocated a contextFieldSet per capture. Now captures are stored inline in the slice; the only allocation is the amortised slice growth.

  2. contextFieldSet.field is now *structLexerField — the field comes from the immutable grammar node owned by the parser for its whole lifetime, so a pointer is safe. Shrinks contextFieldSet from ~200B to ~88B, halving the cost of slice-growth copies (Defer was 5.5k allocs/op).

  3. Decouple the optional trace from parseContext — new traceState holds the writer and depth, allocated only when Trace is enabled. The deferred exit closure now captures only traceState, never the parse context, and the now-unused depth field is removed.

Notes on what I looked at and didn't do

The largest remaining allocation is the ~10.8k/op of parseContext branch copies in disjunction/group. These escape to the heap because parseable and custom nodes hand &ctx.PeekingLexer to user code / reflect.Call, which forces Go's escape analysis to treat every node.Parse(ctx, ...) interface call as potentially retaining ctx. Eliminating those allocations would require pooling or API changes that risk incorrect behaviour if user code retains the lexer, so they're out of scope here.

Validation

  • go test ./... passes (full suite, including the trace tests via participle.Trace).
  • go vet clean for the changed package (pre-existing struct-tag warnings in tests remain).
  • gofmt clean.

Three small changes cut parser allocations ~8% and bytes ~9% on the
microc benchmark (59694 -> 55054 allocs/op, 4572762 -> 4161671 B/op)
without changing observable behaviour.

1. Store deferred field captures by value instead of per-element heap
   allocations. apply is []contextFieldSet (values) rather than
   []*contextFieldSet, so each capture no longer allocates a separate
   contextFieldSet object on the heap; the slice backing array is the
   only allocation and it amortises across the parse.

2. Shrink contextFieldSet by storing field as *structLexerField. The
   field comes from the immutable grammar node owned by the parser, so a
   pointer is safe and the struct drops from ~200B to ~88B, halving the
   cost of slice growth copies.

3. Decouple the optional parse trace from parseContext. traceState holds
   the writer and depth and is only allocated when tracing is enabled;
   the deferred exit closure now captures only the traceState, never the
   parse context. This also lets the unused depth field be removed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant