Skip to content

Parser: Count errors in error_count so binding can skip the error walk - #2217

Merged
marcoroth merged 8 commits into
marcoroth:mainfrom
joelhawksley:perf/skip-error-walk-clean-templates
Aug 13, 2026
Merged

Parser: Count errors in error_count so binding can skip the error walk#2217
marcoroth merged 8 commits into
marcoroth:mainfrom
joelhawksley:perf/skip-error-walk-clean-templates

Conversation

@joelhawksley

@joelhawksley joelhawksley commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

ParseResult#errors collects errors by walking the entire AST on every call, allocating an intermediate array at every node, even when the template parsed with no errors, which is the overwhelmingly common case. Every Herb::Engine compile calls parse_result.errors, so this walk runs once per template.

This PR makes the clean-template path free, and does it once for every binding:

  1. Count the errors in libherb, not in a binding. parser_options_T already carried an error_count field for the max_errors cap. herb_parse now fills it with the exact total and hands it back to the caller, so Ruby, JavaScript, WASM, Java and Rust all get the count instead of Ruby alone.
  2. Skip the recursive walk when the count is zero. Each binding exposes the count on its parse result and returns the top-level errors directly when it is zero.
  3. Collect recursive errors into a shared accumulator. recursive_errors was errors + compact_child_nodes.flat_map(&:recursive_errors), allocating throwaway arrays at every node. It now walks children iteratively into a single accumulator, in all four language bindings.

Split out of #1872 so it can be reviewed and benchmarked on its own.

Implementation notes

The total comes from one allocation-free visitor walk at the end of herb_parse instead of counting as errors are constructed. Six sites attach errors directly instead of going through the generated append_* helper, including the Ruby parse errors in analyze/parse_errors.c, so counting at construction time reported zero errors for a template like <% if condition without end %> while the tree held one. The walk is exact regardless of how an error was attached.

herb_parse only installs its own counter when the caller leaves error_count NULL. A binding that supplies one reads the total back, and a binding that does not gets nil and simply walks, so the fallback is safe by construction.

max_errors now caps every error type instead of only the two tag-matching sites it reached before, which is what the option has always been documented to do. This is a behavior change. The corpus run reports 129 fewer diagnostics across 36,976 files with no file changing verdict.

Benchmark

Measured end-to-end through the github/github monolith's ViewPrecompiler.precompile, which compiles the full template set through the Herb engine (so ParseResult#errors is exercised once per template). Numbers are the full-precompile pass (bin/rails runner), comparing the vendored gem built from main vs. built with this change:

variant wall clock allocations
baseline (main) 34.254s 63,842,961
this PR 32.264s 54,098,228

≈9.74M fewer allocations (−15.3%) and ≈2.0s faster (−5.8%) across the whole precompile.

Correctness

  • Clean template → error_count == 0, the fast path returns the top-level errors and skips the walk.
  • Template with errors → the count matches the tree exactly, including Ruby parse errors and errors suppressed by max_errors.
  • New tests assert error_count == recursive_errors.size across every error path in Ruby, both JavaScript bindings, Java and Rust.
  • Two pre-existing bugs fixed along the way. The Node binding never read max_errors, so the cap silently did nothing there while WASM honoured it. Java seeded the parse result's error list from the document node, which the recursive walk then visited again, double counting every document-level error.
  • Ruby, C, Java, Rust and every JavaScript package suite passes. RuboCop and cargo +nightly fmt --check clean.

This change was produced by Claude Opus 4.8 (GitHub Copilot), acting on behalf of @joelhawksley.

ParseResult#errors collects errors by walking the entire AST
(value.recursive_errors) on every call, allocating along the way — even when
the template parsed with no errors, which is the overwhelmingly common case.

Count errors as they are materialized onto nodes (rb_errors_array_from_c_array)
and record the total on the ParseResult as @total_error_count. When it is zero,
ParseResult#errors returns the top-level errors directly and skips the full
recursive walk entirely.
@github-actions github-actions Bot added ruby Ruby source for the gem and its libraries c C source for the core parser, lexer, and AST c-extension Ruby C extension in ext/ rbs RBS type signatures in sig/ rubygem The herb RubyGem and its packaging labels Aug 13, 2026
Node#recursive_errors was `errors + compact_child_nodes.flat_map(&:recursive_errors)`,
which allocated an intermediate array (and a compacted child array) at every
node. Rewrite it to walk children iteratively into a single shared accumulator,
avoiding the per-node throwaway allocations on large trees.
@joelhawksley
joelhawksley force-pushed the perf/skip-error-walk-clean-templates branch from a8da5bc to ff76eeb Compare August 13, 2026 16:35
@joelhawksley
joelhawksley marked this pull request as ready for review August 13, 2026 16:58
@marcoroth marcoroth added the optimization Compile-time and generated-output optimizations label Aug 13, 2026
@marcoroth marcoroth added this to the v0.11.0 milestone Aug 13, 2026
@marcoroth

Copy link
Copy Markdown
Owner

@joelhawksley just to confirm, is the main baseline that you mentioned in the benchmark section before or after #2199 (e14fe9a)?

@github-actions github-actions Bot added parser HTML+ERB parser wasm WebAssembly build and bindings typescript TypeScript source across the javascript/ packages printer @herb-tools/printer AST printing and lossless reconstruction node @herb-tools/node native Node.js addon node-wasm @herb-tools/node-wasm WebAssembly parser for Node.js rust Rust bindings and the Herb Rust crate java Java bindings and the org.herb package core @herb-tools/core shared AST nodes, interfaces, and utilities cpp C++ source, primarily the WebAssembly bindings action-view-helpers Action View helper support and metadata labels Aug 13, 2026

@marcoroth marcoroth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @joelhawksley!

Similar concern as in #2199, I moved it up a layer too so it lives in libherb and can be shared across all bindings.

error_count is now a real total on parser_options_T, filled in by the generated error helpers and by an exact walk at the end of herb_parse, so the file-scope counter is gone. The walk is needed because a handful of errors get attached directly instead of going through append_*, and without it a template like <% if condition without end %> reported zero errors while the tree held one.

Every binding now carries the count on its parse result and skips the recursive walk when it is zero, so Ruby, JavaScript, WASM, Java and Rust all get it.

I also took your accumulator idea into the TypeScript, Rust and Java node classes, since all three had the same per-node allocation issue.

Please let me know if this still yields the numbers you were seeing in your benchmarks!

Thank you! 🙏🏼

@marcoroth marcoroth changed the title Skip recursive error walk for cleanly-parsed templates Parser: Count errors in error_count so binding can skip the error walk Aug 13, 2026
@joelhawksley

Copy link
Copy Markdown
Contributor Author

Re-benchmarked with the actual upstream code on both sides (the earlier numbers were from a port onto an older herb, so they no longer reflect this branch after your libherb-level rewrite).

To answer your earlier question directly: the main baseline is after #2199 — I branched from e14fe9a7 (the #2199 merge commit), so these numbers isolate this PR's error-count change on top of track_locations.

Methodology

  • Built herb at both refs as full gems (rake templates prism:vendor compile):
  • Swapped each build into a real Rails app (github.com monolith) and ran ActionviewPrecompiler over the full template corpus, routing the ERB handler through Herb::Engine so ParseResult#errors runs on every template compile.
  • Measured the cold ViewPrecompiler.precompile pass (wall time + GC.stat(:total_allocated_objects)), 2 runs each. skipped=0 on both (every template compiled).

Results

build time (run 1 / run 2) allocations (run 1 / run 2)
main (e14fe9a7, after #2199) 31.184s / 31.972s 61,995,829 / 61,998,323
this PR (089c57de) 30.104s / 29.613s 52,250,861 / 52,248,967

Delta: ~9.75M fewer allocations (−15.7%) and ~1.7s faster (−5.4%) on this corpus.

So yes — this still yields the improvement, and the allocation win (which is deterministic run-to-run) holds cleanly on top of #2199. 🎉


This benchmark was produced by Claude Opus 4.8 (GitHub Copilot), acting on behalf of @joelhawksley.

@marcoroth

Copy link
Copy Markdown
Owner

Awesome, thanks for re-checking @joelhawksley! 🙏🏼

Merging this then 🎉

@marcoroth
marcoroth merged commit 4d2aa7c into marcoroth:main Aug 13, 2026
27 checks passed
@joelhawksley
joelhawksley deleted the perf/skip-error-walk-clean-templates branch August 13, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action-view-helpers Action View helper support and metadata c C source for the core parser, lexer, and AST c-extension Ruby C extension in ext/ core @herb-tools/core shared AST nodes, interfaces, and utilities cpp C++ source, primarily the WebAssembly bindings java Java bindings and the org.herb package node @herb-tools/node native Node.js addon node-wasm @herb-tools/node-wasm WebAssembly parser for Node.js optimization Compile-time and generated-output optimizations parser HTML+ERB parser printer @herb-tools/printer AST printing and lossless reconstruction rbs RBS type signatures in sig/ ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging rust Rust bindings and the Herb Rust crate typescript TypeScript source across the javascript/ packages wasm WebAssembly build and bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants