diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f032da..0bad9090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Performance + +- Compiled lexers read in-memory ASCII directly from their static DFA tables + and commit accepted spans in bulk. Optional `CharStream` fast paths preserve + scalar fallback behavior for custom streams and Unicode input. + ### Breaking - Buffered tokens now live once in a compact `TokenStore` and are addressed by diff --git a/docs/issue-78-lexer-benchmark.md b/docs/issue-78-lexer-benchmark.md new file mode 100644 index 00000000..2a9ee318 --- /dev/null +++ b/docs/issue-78-lexer-benchmark.md @@ -0,0 +1,98 @@ +# Issue 78 lexer fast-path benchmark record + +Measurements were taken on 2026-07-17 on an Apple M3 Pro with Rust 1.96.0. +The baseline was `origin/main` at `73f33a407`; generated lexers and parsers +were rebuilt with the matching generator/runtime for each revision. The +grammars-v4 checkout was +`284602b3f23ca54dc30778204ab7ae9e969145e9`. + +## Current-main finding + +Ahead-of-time DFA compilation already removes ATN closure, hashing, and config +allocation from ordinary lexer matching. Issue #78 still applied after that +work in three places: + +- each compiled-DFA symbol read still changed the shared cursor with + `seek(position)` followed by `la(1)`; +- each accepted or recovered span was replayed through `consume_char()` to + rebuild line and column; +- position queries and accept rewinds still used higher-level text or stream + operations. + +The scalar change therefore keeps the compiled DFA and lexer lifecycle model +intact. It adds optional immutable-access and position-summary methods to +`CharStream`, specializes the compiled ASCII walk, and centralizes accepted +position commits. Streams that do not implement the optional methods retain +the original scalar fallback. + +## Lex-only results + +The four configurations were built with the lex-only benchmark runner: + +1. `main`; +2. the scalar fast paths with the ordinary release profile; +3. the scalar fast paths plus `-C target-cpu=native`; +4. the scalar fast paths plus ThinLTO and one codegen unit. + +After all builds completed, the already-built runners were measured in eight +rotating, alternating process rounds per fixture. Each process used 20 warmups +and 100 timed lexes. The table reports the median process average across all 19 +fixtures; ratios below one are faster. + +| Configuration | Geometric ratio vs main | Aggregate ratio vs main | +|---|---:|---:| +| scalar release | 0.8853x | 0.8642x | +| scalar + native CPU | 0.8772x | 0.8671x | +| scalar + ThinLTO / one codegen unit | 0.7725x | 0.7450x | + +Native CPU tuning was effectively neutral relative to the ordinary scalar +build (`0.9908x` geometric, `1.0034x` aggregate). ThinLTO and one codegen unit +improved on the ordinary scalar build by a further `0.8725x` geometric and +`0.8621x` aggregate. + +The ordinary scalar build produced these per-language geometric ratios: + +| Fixtures | Count | Scalar vs main | +|---|---:|---:| +| Kotlin, including two lexer stress fixtures | 6 | 0.9173x | +| C# | 4 | 0.8505x | +| Java | 4 | 0.8598x | +| Trino SQL | 5 | 0.8970x | + +Every source-derived fixture improved. The short Unicode stress fixture had +overlapping 35-41 microsecond samples in the broad run, so it was repeated in +15 alternating process pairs with 100 warmups and 5,000 timed lexes. Its +median process average was 34.1 microseconds for the scalar build and 34.8 +microseconds for main (`0.9805x`). The ASCII stress fixture measured `0.8872x` +in the broad run. + +## End-to-end parse results + +The same ordinary baseline and scalar binaries were measured over the 17 +source-derived fixtures in six alternating process rounds, each with 5 +warmups and 20 timed parses. + +| Fixtures | Scalar vs main | +|---|---:| +| Kotlin | 0.9977x | +| C# | 0.9946x | +| Java | 1.0025x | +| Trino SQL | 0.9900x | +| **Geometric mean** | **0.9958x** | +| **Aggregate time** | **0.9961x** | + +The largest fixture ratio was `1.0182x` on the Java Trino filter fixture, so +all 17 fixtures remained within the 2% regression threshold. + +## Fast-path counters + +A three-iteration instrumentation run demonstrated that the two synthetic +fixtures use the intended paths: + +| Fixture | Direct ASCII reads | Generic reads | Scalar replay | Bulk committed | +|---|---:|---:|---:|---:| +| ASCII stress | 6,057 | 0 | 0 | 5,193 | +| Unicode fallback | 0 | 2,181 | 0 | 1,845 | + +The Unicode stream remains indexed by scalar value. The generic count records +immutable scalar lookups; it does not indicate cursor mutation or replay. diff --git a/src/atn/lexer.rs b/src/atn/lexer.rs index db1cedfa..95c1046c 100644 --- a/src/atn/lexer.rs +++ b/src/atn/lexer.rs @@ -717,24 +717,19 @@ where let accept = match token_match { MatchResult::Accept(accept) => accept, MatchResult::NoViableAlt { stop } => { - lexer.input_mut().seek(start); + lexer.commit_position(start, start); if lexer.input_mut().la(1) == EOF { lexer.set_hit_eof(true); return lexer.emit_eof_or_pending(sink); } record_token_recognition_error(lexer, start, stop); - while lexer.input().index() < stop { - lexer.consume_char(); - } + lexer.commit_position(start, stop); continuing_more = false; continue; } }; - lexer.input_mut().seek(start); - while lexer.input().index() < accept.position { - lexer.consume_char(); - } + lexer.commit_position(start, accept.position); let token_type = atn .rule_to_token_type() @@ -1076,6 +1071,72 @@ fn match_token_compiled( start_state: u16, start: usize, ) -> Option +where + I: CharStream, +{ + if let Some(input) = lexer.input().contiguous_ascii() { + return match_token_compiled_ascii(input, dfa, start_state, start); + } + match_token_compiled_generic(lexer, dfa, start_state, start) +} + +fn match_token_compiled_ascii( + input: &[u8], + dfa: &CompiledLexerDfa, + start_state: u16, + start: usize, +) -> Option { + let mut state = start_state; + let mut position = start; + let mut best: Option = None; + let mut error_stop = start; + let mut eof_edges = 0_u32; + #[cfg(feature = "perf-counters")] + let mut direct_chars = 0; + let result = loop { + if let Some(accept) = dfa.accept(state) { + record_compiled_accept(accept, position, &mut best); + } + let (target, at_eof) = if position < input.len() { + let symbol = input[position]; + #[cfg(feature = "perf-counters")] + { + direct_chars += 1; + } + error_stop = error_stop.max(position.saturating_add(1)); + (dfa.ascii_target(state, symbol), false) + } else { + eof_edges += 1; + if eof_edges > MAX_COMPILED_EOF_EDGES { + break None; + } + (dfa.eof_target(state), true) + }; + if target == DEAD_STATE { + break Some(best.map_or( + MatchResult::NoViableAlt { stop: error_stop }, + MatchResult::Accept, + )); + } + if target == ESCAPE_STATE { + break None; + } + if !at_eof { + position += 1; + } + state = target; + }; + #[cfg(feature = "perf-counters")] + crate::perf::record_lexer_direct_ascii(direct_chars); + result +} + +fn match_token_compiled_generic( + lexer: &mut BaseLexer, + dfa: &CompiledLexerDfa, + start_state: u16, + start: usize, +) -> Option where I: CharStream, { @@ -1570,14 +1631,21 @@ fn display_error_text(text: &str) -> String { /// Reads the Unicode scalar value at an absolute character-stream index. /// -/// The interpreter explores many paths at different input offsets, so it seeks -/// the shared input stream before each lookahead instead of cloning the stream. +/// Streams with immutable random access avoid touching their committed cursor; +/// custom streams retain the compatible seek-and-lookahead path. fn symbol_at(lexer: &mut BaseLexer, position: usize) -> i32 where I: CharStream, { - lexer.input_mut().seek(position); - lexer.input_mut().la(1) + let symbol = lexer.input().symbol_at(position).unwrap_or_else(|| { + lexer.input_mut().seek(position); + lexer.input_mut().la(1) + }); + #[cfg(feature = "perf-counters")] + if symbol != EOF { + crate::perf::record_lexer_generic_char(); + } + symbol } #[cfg(test)] diff --git a/src/atn/lexer_dfa.rs b/src/atn/lexer_dfa.rs index e78ed68d..c2ed484c 100644 --- a/src/atn/lexer_dfa.rs +++ b/src/atn/lexer_dfa.rs @@ -170,6 +170,13 @@ impl CompiledLexerDfa { .get(self.states[usize::from(state)].accept as usize) } + /// Target for one byte from a stream known to contain only ASCII. + pub(super) fn ascii_target(&self, state: u16, symbol: u8) -> u16 { + debug_assert!(symbol.is_ascii()); + let compiled = &self.states[usize::from(state)]; + self.ascii_rows[compiled.ascii_row as usize][usize::from(symbol)] + } + /// `LexerTransition` target for a non-EOF symbol, or [`DEAD_STATE`]. pub(super) fn char_target(&self, state: u16, symbol: i32) -> u16 { let compiled = &self.states[usize::from(state)]; @@ -921,7 +928,8 @@ mod tests { use super::*; use crate::atn::lexer::{next_token, next_token_compiled, next_token_compiled_with_hooks}; use crate::atn::serialized::{AtnDeserializer, SerializedAtn}; - use crate::char_stream::InputStream; + use crate::char_stream::{CharStream, InputStream, TextInterval}; + use crate::int_stream::IntStream; use crate::lexer::BaseLexer; use crate::recognizer::RecognizerData; use crate::token::{TOKEN_EOF, Token, TokenSink, TokenStore}; @@ -931,13 +939,18 @@ mod tests { struct TokenSnapshot { token_type: i32, text: String, + line: usize, + column: usize, } - fn compiled_token( - lexer: &mut BaseLexer, + fn compiled_token( + lexer: &mut BaseLexer, atn: &LexerAtn, dfa: &CompiledLexerDfa, - ) -> TokenSnapshot { + ) -> TokenSnapshot + where + I: CharStream, + { let mut store = TokenStore::new(lexer.source_text(), lexer.source_name()); let mut sink = TokenSink::new(&mut store); let id = next_token_compiled(lexer, &mut sink, atn, dfa).expect("test token should fit"); @@ -945,6 +958,8 @@ mod tests { TokenSnapshot { token_type: token.token_type(), text: token.text().to_owned(), + line: token.line(), + column: token.column(), } } @@ -956,6 +971,44 @@ mod tests { TokenSnapshot { token_type: token.token_type(), text: token.text().to_owned(), + line: token.line(), + column: token.column(), + } + } + + #[derive(Clone, Debug)] + struct FallbackInput(InputStream); + + impl IntStream for FallbackInput { + fn consume(&mut self) { + self.0.consume(); + } + + fn la(&mut self, offset: isize) -> i32 { + self.0.la(offset) + } + + fn index(&self) -> usize { + self.0.index() + } + + fn seek(&mut self, index: usize) { + self.0.seek(index); + } + + fn size(&self) -> usize { + self.0.size() + } + + fn source_name(&self) -> &str { + self.0.source_name() + } + } + + // Deliberately implements none of the optional fast paths. + impl CharStream for FallbackInput { + fn text(&self, interval: TextInterval) -> String { + self.0.text(interval) } } @@ -1103,6 +1156,58 @@ mod tests { assert_eq!(compiled_token(&mut lexer, &atn, &dfa).token_type, TOKEN_EOF); } + #[test] + fn compiled_dfa_keeps_custom_streams_on_the_compatible_fallback() { + let atn = two_rule_atn(false); + let dfa = CompiledLexerDfa::compile(&atn); + let mut lexer = BaseLexer::new(FallbackInput(InputStream::new(" ab")), recognizer_data()); + + let token = compiled_token(&mut lexer, &atn, &dfa); + assert_eq!(token.token_type, 1); + assert_eq!(token.text, "ab"); + assert_eq!((token.line, token.column), (1, 1)); + assert_eq!(lexer.input().index(), 3); + } + + #[cfg(feature = "perf-counters")] + #[test] + fn lexer_counters_distinguish_ascii_unicode_and_replay_paths() { + let ascii_atn = two_rule_atn(false); + let ascii_dfa = CompiledLexerDfa::compile(&ascii_atn); + crate::perf::reset(); + let mut ascii = BaseLexer::new(InputStream::new(" ab"), recognizer_data()); + let token = compiled_token(&mut ascii, &ascii_atn, &ascii_dfa); + assert_eq!(token.text, "ab"); + let [direct, generic, replay, bulk] = crate::perf::lexer_snapshot(); + assert!(direct >= 3, "{direct}"); + assert_eq!(generic, 0); + assert_eq!(replay, 0); + assert_eq!(bulk, 3); + + let unicode_atn = wide_range_atn(); + let unicode_dfa = CompiledLexerDfa::compile(&unicode_atn); + crate::perf::reset(); + let mut unicode = BaseLexer::new(InputStream::new("ĀĂ"), recognizer_data()); + let token = compiled_token(&mut unicode, &unicode_atn, &unicode_dfa); + assert_eq!(token.text, "ĀĂ"); + let [direct, generic, replay, bulk] = crate::perf::lexer_snapshot(); + assert_eq!(direct, 0); + assert!(generic >= 2, "{generic}"); + assert_eq!(replay, 0); + assert_eq!(bulk, 2); + + crate::perf::reset(); + let mut fallback = + BaseLexer::new(FallbackInput(InputStream::new(" ab")), recognizer_data()); + let token = compiled_token(&mut fallback, &ascii_atn, &ascii_dfa); + assert_eq!(token.text, "ab"); + let [direct, generic, replay, bulk] = crate::perf::lexer_snapshot(); + assert_eq!(direct, 0); + assert!(generic >= 3, "{generic}"); + assert_eq!(replay, 3); + assert_eq!(bulk, 0); + } + #[test] fn compiled_dfa_reports_recognition_errors_like_the_interpreter() { let atn = wide_range_atn(); diff --git a/src/char_stream.rs b/src/char_stream.rs index 37c9e19f..1e58e23c 100644 --- a/src/char_stream.rs +++ b/src/char_stream.rs @@ -21,9 +21,57 @@ impl TextInterval { } } +/// Line/column effect of consuming a half-open character span. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PositionSummary { + /// Number of newline characters in the span. + pub line_breaks: usize, + /// Number of characters after the final newline, or the complete span + /// length when no newline is present. + pub trailing_columns: usize, +} + +impl PositionSummary { + /// Applies this summary to an existing one-based line and zero-based column. + pub const fn apply(self, line: usize, column: usize) -> (usize, usize) { + let line = line.saturating_add(self.line_breaks); + let column = if self.line_breaks == 0 { + column.saturating_add(self.trailing_columns) + } else { + self.trailing_columns + }; + (line, column) + } +} + pub trait CharStream: IntStream { fn text(&self, interval: TextInterval) -> String; + /// Reads one Unicode scalar at an absolute character index without moving + /// the stream cursor. + /// + /// Returning `None` leaves callers on the compatible `seek` + `la` + /// fallback. Implementations that support immutable access return + /// [`EOF`] when `index` is outside the input. + fn symbol_at(&self, _index: usize) -> Option { + None + } + + /// Returns the complete input as ASCII bytes when character and byte + /// indexes are identical. + fn contiguous_ascii(&self) -> Option<&[u8]> { + None + } + + /// Summarizes source-position changes for the half-open character interval + /// `[start, end)` without moving the stream cursor. + /// + /// Implementations may clamp `end` to the input size. Returning `None` + /// leaves callers on scalar replay. + fn position_summary(&self, _start: usize, _end: usize) -> Option { + None + } + /// Returns the complete backing UTF-8 source when it can be shared with a /// token store. /// @@ -175,6 +223,50 @@ impl CharStream for InputStream { String::new() } + fn symbol_at(&self, index: usize) -> Option { + Some( + self.data + .get(&self.source, index) + .map_or(EOF, |ch| u32::from(ch).cast_signed()), + ) + } + + fn contiguous_ascii(&self) -> Option<&[u8]> { + matches!(self.data, InputData::Ascii).then(|| self.source.as_bytes()) + } + + fn position_summary(&self, start: usize, end: usize) -> Option { + if start > end { + return None; + } + let len = self.data.len(&self.source); + let start = start.min(len); + let end = end.min(len); + + let mut summary = PositionSummary::default(); + let mut note = |is_newline| { + if is_newline { + summary.line_breaks += 1; + summary.trailing_columns = 0; + } else { + summary.trailing_columns += 1; + } + }; + match &self.data { + InputData::Ascii => { + for &byte in &self.source.as_bytes()[start..end] { + note(byte == b'\n'); + } + } + InputData::Unicode { chars, .. } => { + for &ch in &chars[start..end] { + note(ch == '\n'); + } + } + } + Some(summary) + } + fn text_source_interval(&self, interval: TextInterval) -> Option<(Rc, usize, usize)> { let len = self.data.len(&self.source); if interval.is_empty() || len == 0 { @@ -227,4 +319,57 @@ mod tests { input.seek(99); assert_eq!(input.la(1), EOF); } + + #[test] + fn optional_fast_paths_preserve_scalar_indexes_and_positions() { + let ascii = InputStream::new("ab\ncd"); + assert_eq!(ascii.contiguous_ascii(), Some(&b"ab\ncd"[..])); + assert_eq!(ascii.symbol_at(2), Some('\n' as i32)); + assert_eq!(ascii.symbol_at(5), Some(EOF)); + assert_eq!( + ascii.position_summary(1, 5), + Some(PositionSummary { + line_breaks: 1, + trailing_columns: 2, + }) + ); + assert_eq!( + ascii.position_summary(5, 99), + Some(PositionSummary::default()) + ); + assert_eq!(ascii.position_summary(4, 2), None); + assert_eq!(ascii.position_summary(7, 6), None); + + let unicode = InputStream::new("aβ\nγ"); + assert_eq!(unicode.contiguous_ascii(), None); + assert_eq!(unicode.symbol_at(1), Some('β' as i32)); + assert_eq!(unicode.symbol_at(4), Some(EOF)); + assert_eq!( + unicode.position_summary(1, 4), + Some(PositionSummary { + line_breaks: 1, + trailing_columns: 1, + }) + ); + } + + #[test] + fn position_summary_applies_to_existing_coordinates() { + assert_eq!( + PositionSummary { + line_breaks: 0, + trailing_columns: 3, + } + .apply(4, 7), + (4, 10) + ); + assert_eq!( + PositionSummary { + line_breaks: 2, + trailing_columns: 3, + } + .apply(4, 7), + (6, 3) + ); + } } diff --git a/src/lexer.rs b/src/lexer.rs index cb94e34f..afa85db9 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -869,6 +869,9 @@ where let Some(index) = absolute.filter(|index| *index < self.input.size()) else { return EOF; }; + if let Some(symbol) = self.input.symbol_at(index) { + return symbol; + } self.input .text(TextInterval::new(index, index)) .chars() @@ -896,6 +899,35 @@ where } } + /// Commits a predicted input span while keeping the current line and column + /// as the coordinates at `start`. + pub(crate) fn commit_position(&mut self, start: usize, target: usize) { + self.reposition_from(start, self.line, self.column, target); + } + + fn reposition_from(&mut self, start: usize, line: usize, column: usize, target: usize) { + let start = start.min(self.input.size()); + let target = target.max(start).min(self.input.size()); + if let Some(summary) = self.input.position_summary(start, target) { + self.input.seek(target); + (self.line, self.column) = summary.apply(line, column); + #[cfg(feature = "perf-counters")] + crate::perf::record_lexer_bulk_commit(target - start); + return; + } + + self.input.seek(start); + self.line = line; + self.column = column; + #[cfg(feature = "perf-counters")] + let before = self.input.index(); + while self.input.index() < target && self.input.la(1) != EOF { + self.consume_char(); + } + #[cfg(feature = "perf-counters")] + crate::perf::record_lexer_scalar_replay(self.input.index().saturating_sub(before)); + } + /// Rewinds or advances the input cursor to a token accept boundary. /// /// Some generated lexers intentionally accept a longer path to disambiguate @@ -904,12 +936,12 @@ where /// position consistent after moving the cursor backwards. pub fn reset_accept_position(&mut self, index: usize) { let target = index.max(self.token_start); - self.input.seek(self.token_start); - self.line = self.token_start_line; - self.column = self.token_start_column; - while self.input.index() < target && self.input.la(1) != EOF { - self.consume_char(); - } + self.reposition_from( + self.token_start, + self.token_start_line, + self.token_start_column, + target, + ); } /// Moves the current token start forward within the consumed input span. @@ -1090,6 +1122,9 @@ where if position <= self.token_start { return (line, column); } + if let Some(summary) = self.input.position_summary(self.token_start, position) { + return summary.apply(line, column); + } for ch in self .input .text(TextInterval::new(self.token_start, position - 1)) @@ -1542,6 +1577,48 @@ mod tests { assert_eq!(token.byte_span(), 0..2); } + #[test] + fn position_commits_and_rewinds_preserve_line_and_column() { + let data = RecognizerData::new( + "T", + Vocabulary::new( + std::iter::empty::>(), + std::iter::empty::>(), + std::iter::empty::>(), + ), + ); + let mut lexer = BaseLexer::new(InputStream::new("ab\nγd"), data); + lexer.begin_token(); + + lexer.commit_position(0, 5); + assert_eq!(lexer.input().index(), 5); + assert_eq!((lexer.line(), lexer.column()), (2, 2)); + assert_eq!(lexer.column_at(2), 2); + assert_eq!(lexer.column_at(4), 1); + + lexer.reset_accept_position(3); + assert_eq!(lexer.input().index(), 3); + assert_eq!((lexer.line(), lexer.column()), (2, 0)); + } + + #[test] + fn custom_stream_position_commit_replays_without_fast_path_methods() { + let data = RecognizerData::new( + "T", + Vocabulary::new( + std::iter::empty::>(), + std::iter::empty::>(), + std::iter::empty::>(), + ), + ); + let mut lexer = BaseLexer::new(UnsharedInput(InputStream::new("a\nb")), data); + lexer.begin_token(); + + lexer.commit_position(0, 3); + assert_eq!(lexer.input().index(), 3); + assert_eq!((lexer.line(), lexer.column()), (2, 1)); + } + #[test] fn semantic_hook_errors_are_deduplicated_per_token_coordinate() { let data = RecognizerData::new( diff --git a/src/lib.rs b/src/lib.rs index fbce91dc..edafd41d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,7 @@ pub mod tree; pub mod vocabulary; pub use atn::parser::{ParserAtnPrediction, ParserAtnSimulator, ParserAtnSimulatorError}; -pub use char_stream::{CharStream, InputStream, TextInterval}; +pub use char_stream::{CharStream, InputStream, PositionSummary, TextInterval}; pub use dfa::{DfaStateId, DfaTransition, ParserDfa, ParserDfaStateView, ParserDfaStats}; pub use errors::{AntlrError, ConsoleErrorListener, ErrorListener}; pub use generated::{GeneratedLexer, GeneratedParser, GrammarMetadata}; diff --git a/src/perf.rs b/src/perf.rs index 884132f4..e99e5ab3 100644 --- a/src/perf.rs +++ b/src/perf.rs @@ -1,4 +1,4 @@ -//! Lightweight counters for prediction performance investigations. +//! Lightweight counters for lexer and prediction performance investigations. #![allow(clippy::missing_const_for_thread_local)] @@ -47,6 +47,10 @@ struct Counters { dfa_cache_publications: u64, dfa_cache_publication_nanos: u64, dfa_cache_publication_states: u64, + lexer_direct_ascii_chars: u64, + lexer_generic_chars: u64, + lexer_scalar_replay_chars: u64, + lexer_bulk_committed_chars: u64, decisions: BTreeMap, } @@ -270,6 +274,36 @@ pub(crate) fn record_dfa_cache_publication(nanos: u128, states: usize) { }); } +pub(crate) fn record_lexer_direct_ascii(count: usize) { + with_counters(|counters| { + counters.lexer_direct_ascii_chars = counters + .lexer_direct_ascii_chars + .saturating_add(u64::try_from(count).unwrap_or(u64::MAX)); + }); +} + +pub(crate) fn record_lexer_generic_char() { + with_counters(|counters| { + counters.lexer_generic_chars = counters.lexer_generic_chars.saturating_add(1); + }); +} + +pub(crate) fn record_lexer_scalar_replay(count: usize) { + with_counters(|counters| { + counters.lexer_scalar_replay_chars = counters + .lexer_scalar_replay_chars + .saturating_add(u64::try_from(count).unwrap_or(u64::MAX)); + }); +} + +pub(crate) fn record_lexer_bulk_commit(count: usize) { + with_counters(|counters| { + counters.lexer_bulk_committed_chars = counters + .lexer_bulk_committed_chars + .saturating_add(u64::try_from(count).unwrap_or(u64::MAX)); + }); +} + pub fn reset() { COUNTERS.with(|counters| *counters.borrow_mut() = Counters::default()); } @@ -305,7 +339,7 @@ fn dump_decisions(counters: &Counters) { } } -const fn totals(counters: &Counters) -> [(&'static str, u64); 40] { +const fn totals(counters: &Counters) -> [(&'static str, u64); 44] { [ ("prediction.adaptive_calls", counters.adaptive_calls), ( @@ -377,9 +411,35 @@ const fn totals(counters: &Counters) -> [(&'static str, u64); 40] { "dfa_cache.publication_states", counters.dfa_cache_publication_states, ), + ( + "lexer.direct_ascii_chars", + counters.lexer_direct_ascii_chars, + ), + ("lexer.generic_chars", counters.lexer_generic_chars), + ( + "lexer.scalar_replay_chars", + counters.lexer_scalar_replay_chars, + ), + ( + "lexer.bulk_committed_chars", + counters.lexer_bulk_committed_chars, + ), ] } +#[cfg(test)] +pub(crate) fn lexer_snapshot() -> [u64; 4] { + COUNTERS.with(|counters| { + let counters = counters.borrow(); + [ + counters.lexer_direct_ascii_chars, + counters.lexer_generic_chars, + counters.lexer_scalar_replay_chars, + counters.lexer_bulk_committed_chars, + ] + }) +} + fn print_counter(name: &str, value: u64) { #[allow(clippy::print_stderr)] { diff --git a/tools/parse-bench/README.md b/tools/parse-bench/README.md index 20729efc..a153229e 100644 --- a/tools/parse-bench/README.md +++ b/tools/parse-bench/README.md @@ -73,10 +73,47 @@ Use `--rust-generated-only` for Adaptive LL delivery evidence so the Rust generator fails if any parser rule lacks a generated body and the Rust runner fails if a generated parser path falls back to the interpreter. +### Lex-only measurements + +Use `--phase lex` to time generated Rust lexing and token buffering without +constructing a parser: + +```bash +python3 tools/parse-bench/run.py \ + --phase lex \ + --languages kotlin,csharp,java,trino \ + --runtimes rust-antlr \ + --iters 20 \ + --warmups 3 +``` + +The source-derived fixtures cover ordinary Kotlin, C#, Java, and Trino input. +Two lex-only Kotlin fixtures add concentrated ASCII coverage for long +identifiers, strings, comments, whitespace, and punctuation, plus mixed-script +coverage for the Unicode fallback. + +Use a detached checkout for same-machine baseline comparisons, and select the +compiler-level configurations explicitly: + +```bash +python3 tools/parse-bench/run.py \ + --phase lex \ + --runtimes rust-antlr \ + --runtime-root /tmp/antlr-runtime-main \ + --rust-native \ + --rust-thin-lto +``` + +`--rust-native` adds `-C target-cpu=native`. `--rust-thin-lto` writes +`lto = "thin"` and `codegen-units = 1` in the generated benchmark workspace, +where Cargo profile settings control the final application and its +dependencies. + ## Prediction Memory Counters -Set `ANTLR_PERF_DUMP=1` to build the Rust runner with prediction counters and -print context-store measurements: +Set `ANTLR_PERF_DUMP=1` to build the Rust runner with performance counters. +Parse runs print prediction and context-store measurements; lex-only runs print +lexer direct-ASCII, generic-character, scalar-replay, and bulk-commit counts: ```bash ANTLR_PERF_DUMP=1 python3 tools/parse-bench/run.py \ diff --git a/tools/parse-bench/fixtures/kotlin/lexer-ascii-stress.kt b/tools/parse-bench/fixtures/kotlin/lexer-ascii-stress.kt new file mode 100644 index 00000000..0796dc55 --- /dev/null +++ b/tools/parse-bench/fixtures/kotlin/lexer-ascii-stress.kt @@ -0,0 +1,36 @@ +package lexerbench + +/* + * Long block comment body used to measure delimiter-heavy runs. The repeated + * words intentionally stay plain ASCII and keep the lexer in comment states: + * alpha beta gamma delta epsilon alpha beta gamma delta epsilon alpha beta + * gamma delta epsilon alpha beta gamma delta epsilon alpha beta gamma delta. + */ + +private val identifier_with_a_deliberately_long_ascii_name_for_lexer_throughput_measurement = + "A deliberately long ASCII string body with spaces, digits 0123456789, and repeated text. " + + "A deliberately long ASCII string body with spaces, digits 0123456789, and repeated text." + +private fun punctuationHeavy(value: Int): Int { + val first_identifier_with_a_long_continuation = value + 1 + val second_identifier_with_a_long_continuation = value * 2 + val third_identifier_with_a_long_continuation = value - 3 + + return (((first_identifier_with_a_long_continuation + second_identifier_with_a_long_continuation) * + (third_identifier_with_a_long_continuation - first_identifier_with_a_long_continuation)) / + ((second_identifier_with_a_long_continuation + 1).coerceAtLeast(1))) % 97 +} + +// Long line comments exercise a common self-loop body and a newline boundary. +// alpha beta gamma delta epsilon alpha beta gamma delta epsilon alpha beta gamma delta epsilon. + +private val compactPunctuation = listOf(1,2,3,4,5,6,7,8,9,10).map{it+1}.filter{it%2==0} + + + +private val whitespaceSeparatedValues = + listOf( + identifier_with_a_deliberately_long_ascii_name_for_lexer_throughput_measurement, + "second long ASCII string value with escaped quote \" and escaped slash \\", + "third long ASCII string value with punctuation !@#%^&*()[]{};:,.?", + ) diff --git a/tools/parse-bench/fixtures/kotlin/lexer-unicode-fallback.kt b/tools/parse-bench/fixtures/kotlin/lexer-unicode-fallback.kt new file mode 100644 index 00000000..e86c1200 --- /dev/null +++ b/tools/parse-bench/fixtures/kotlin/lexer-unicode-fallback.kt @@ -0,0 +1,14 @@ +package lexerbench + +// One non-ASCII scalar makes InputStream retain Unicode scalar indexing for the +// complete source, including otherwise ASCII identifiers, strings, and spaces. +private val ΕλληνικοΑναγνωριστικοΜεΜεγαλοΟνομα = "Καλημερα κοσμε" +private val РусскийИдентификаторСДлиннымИменем = "Привет, мир" +private val 日本語の識別子 = "こんにちは世界" +private val mixedAsciiAndUnicodeIdentifier_Δοκιμη_Тест_試験 = "aβcдe界" + +private fun combineUnicodeValues(): String = + ΕλληνικοΑναγνωριστικοΜεΜεγαλοΟνομα + + РусскийИдентификаторСДлиннымИменем + + 日本語の識別子 + + mixedAsciiAndUnicodeIdentifier_Δοκιμη_Тест_試験 diff --git a/tools/parse-bench/fixtures/manifest.json b/tools/parse-bench/fixtures/manifest.json index ff9be440..5d52322a 100644 --- a/tools/parse-bench/fixtures/manifest.json +++ b/tools/parse-bench/fixtures/manifest.json @@ -1,5 +1,21 @@ { "fixtures": [ + { + "language": "kotlin", + "path": "kotlin/lexer-ascii-stress.kt", + "source": "repository synthetic benchmark", + "license": "BSD-3-Clause", + "description": "Lex-only ASCII stress input with long identifiers, strings, comments, whitespace, and punctuation-heavy expressions.", + "phases": ["lex"] + }, + { + "language": "kotlin", + "path": "kotlin/lexer-unicode-fallback.kt", + "source": "repository synthetic benchmark", + "license": "BSD-3-Clause", + "description": "Lex-only mixed-script input that forces Unicode scalar-index fallback across the complete stream.", + "phases": ["lex"] + }, { "language": "kotlin", "path": "kotlin/jetbrains-kotlin-lazy-bodies-test.kt", diff --git a/tools/parse-bench/run.py b/tools/parse-bench/run.py index 84f933a9..aa339337 100755 --- a/tools/parse-bench/run.py +++ b/tools/parse-bench/run.py @@ -117,6 +117,7 @@ class LanguageSpec: } RUNTIMES = ("rust-antlr", "python-antlr", "go-antlr", "tree-sitter") +PHASES = ("parse", "lex") @dataclasses.dataclass(frozen=True) @@ -126,6 +127,7 @@ class Fixture: source: str license: str description: str + phases: tuple[str, ...] = ("parse", "lex") @property def name(self) -> str: @@ -194,12 +196,13 @@ def require_path(path: Path, label: str) -> None: raise SystemExit(f"{label} does not exist: {path}") -def load_fixtures(languages: set[str]) -> list[Fixture]: +def load_fixtures(languages: set[str], phase: str) -> list[Fixture]: manifest = json.loads((FIXTURE_ROOT / "manifest.json").read_text()) fixtures = [] for item in manifest["fixtures"]: language = str(item["language"]) - if language not in languages: + phases = tuple(str(value) for value in item.get("phases", PHASES)) + if language not in languages or phase not in phases: continue fixture = Fixture( language=language, @@ -207,6 +210,7 @@ def load_fixtures(languages: set[str]) -> list[Fixture]: source=str(item["source"]), license=str(item["license"]), description=str(item["description"]), + phases=phases, ) require_path(fixture.abs_path, f"fixture {fixture.path}") fixtures.append(fixture) @@ -324,6 +328,7 @@ def generate_rust_modules( interp_dir: Path, rust_generated_dir: Path, require_generated_parser: bool, + runtime_root: Path, ) -> None: common = [ "cargo", @@ -331,7 +336,7 @@ def generate_rust_modules( "--quiet", "--release", "--manifest-path", - str(ROOT / "Cargo.toml"), + str(runtime_root / "Cargo.toml"), "--bin", "antlr4-rust-gen", "--", @@ -360,28 +365,39 @@ def generate_rust_modules( run(parser_cmd) -def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: +def write_rust_runner( + work_dir: Path, + specs: list[LanguageSpec], + runtime_root: Path, + rust_thin_lto: bool, +) -> Path: runner = work_dir / "rust-runner" generated = runner / "src" / "generated" generated.mkdir(parents=True, exist_ok=True) - (runner / "Cargo.toml").write_text( - "\n".join( + manifest = [ + "[package]", + 'name = "parse-bench-rust-runner"', + 'version = "0.0.0"', + 'edition = "2024"', + "", + "[features]", + "default = []", + 'perf-counters = ["antlr-rust-runtime/perf-counters"]', + "", + "[dependencies]", + f'antlr-rust-runtime = {{ path = "{runtime_root}" }}', + "", + ] + if rust_thin_lto: + manifest.extend( [ - "[package]", - 'name = "parse-bench-rust-runner"', - 'version = "0.0.0"', - 'edition = "2024"', - "", - "[features]", - "default = []", - 'perf-counters = ["antlr-rust-runtime/perf-counters"]', - "", - "[dependencies]", - f'antlr-rust-runtime = {{ path = "{ROOT}" }}', + "[profile.release]", + 'lto = "thin"', + "codegen-units = 1", "", ] ) - ) + (runner / "Cargo.toml").write_text("\n".join(manifest)) modules = "\n".join( f" pub mod {spec.rust_lexer_module};\n pub mod {spec.rust_parser_module};" for spec in specs @@ -390,6 +406,7 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: f' "{spec.name}" => parse_{spec.name}(&src).map_err(|err| err.to_string())?,' for spec in specs ) + lex_arms = "\n".join(f' "{spec.name}" => lex_{spec.name}(&src)?,' for spec in specs) stats_arms = "\n".join( f' "{spec.name}" => prediction_stats_{spec.name}(&src).map_err(|err| err.to_string())?,' for spec in specs @@ -428,12 +445,14 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: let mut args = env::args().skip(1); let mut language: Option = None; let mut input: Option = None; + let mut phase = "parse".to_owned(); let mut iters = 1_usize; let mut warmups = 0_usize; while let Some(arg) = args.next() {{ match arg.as_str() {{ "--language" => language = args.next(), "--input" => input = args.next().map(PathBuf::from), + "--phase" => phase = args.next().ok_or("missing value for --phase")?, "--iters" => iters = parse_usize(args.next(), "--iters")?, "--warmups" => warmups = parse_usize(args.next(), "--warmups")?, other => return Err(format!("unknown argument: {{other}}")), @@ -444,12 +463,15 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: if iters == 0 {{ return Err("--iters must be greater than 0".to_owned()); }} + if phase != "parse" && phase != "lex" {{ + return Err(format!("unsupported phase: {{phase}}")); + }} let src = fs::read_to_string(&input) .map_err(|err| format!("failed to read {{}}: {{err}}", input.display()))?; let collect_stats = env::var_os("ANTLR_PERF_DUMP").is_some(); for _ in 0..warmups {{ - parse_once(&language, &src)?; + run_once(&phase, &language, &src)?; }} #[cfg(feature = "perf-counters")] antlr4_runtime::reset_prediction_perf_counters(); @@ -458,7 +480,7 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: let mut total_ns = 0_u128; for _ in 0..iters {{ let started = Instant::now(); - parse_once(&language, &src)?; + run_once(&phase, &language, &src)?; let elapsed = started.elapsed().as_nanos(); min_ns = min_ns.min(elapsed); total_ns += elapsed; @@ -469,7 +491,7 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: if collect_stats {{ antlr4_runtime::dump_prediction_perf_counters(); }} - if collect_stats {{ + if collect_stats && phase == "parse" {{ let (context_stats, dfa_stats) = prediction_stats_once(&language, &src)?; dump_prediction_context_stats(context_stats); dump_parser_dfa_stats(dfa_stats); @@ -488,6 +510,22 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: Ok(()) }} +fn lex_once(language: &str, src: &str) -> Result<(), String> {{ + match language {{ +{lex_arms} + other => return Err(format!("unsupported language: {{other}}")), + }} + Ok(()) +}} + +fn run_once(phase: &str, language: &str, src: &str) -> Result<(), String> {{ + match phase {{ + "parse" => parse_once(language, src), + "lex" => lex_once(language, src), + other => Err(format!("unsupported phase: {{other}}")), + }} +}} + fn prediction_stats_once( language: &str, src: &str, @@ -568,7 +606,14 @@ def write_rust_runner(work_dir: Path, specs: list[LanguageSpec]) -> Path: def rust_parse_function(spec: LanguageSpec) -> str: - return f"""fn parse_{spec.name}(src: &str) -> Result<(), antlr4_runtime::AntlrError> {{ + return f"""fn lex_{spec.name}(src: &str) -> Result<(), String> {{ + let lexer = generated::{spec.rust_lexer_module}::{spec.rust_lexer_type}::new(InputStream::new(src)); + let tokens = CommonTokenStream::new(lexer); + black_box(tokens.token_count()); + Ok(()) +}} + +fn parse_{spec.name}(src: &str) -> Result<(), antlr4_runtime::AntlrError> {{ let lexer = generated::{spec.rust_lexer_module}::{spec.rust_lexer_type}::new(InputStream::new(src)); let tokens = CommonTokenStream::new(lexer); let mut parser = generated::{spec.rust_parser_module}::{spec.rust_parser_type}::new(tokens); @@ -911,10 +956,15 @@ def prepare_work( runtimes: set[str], ) -> dict[str, Path]: work_dir = args.work_dir - clear_work_dir(work_dir) + clear_work_dir(work_dir, args.runtime_root) work_dir.mkdir(parents=True, exist_ok=True) - rust_runner = write_rust_runner(work_dir, specs) + rust_runner = write_rust_runner( + work_dir, + specs, + args.runtime_root, + args.rust_thin_lto, + ) rust_generated = rust_runner / "src" / "generated" py_gen = work_dir / "python-gen" go_runner = write_go_runner(work_dir, specs) @@ -933,6 +983,7 @@ def prepare_work( interp_dir, rust_generated, args.rust_generated_only, + args.runtime_root, ) if "python-antlr" in runtimes: @@ -963,10 +1014,22 @@ def prepare_work( runners: dict[str, Path] = {} if "rust-antlr" in runtimes: - rust_build_cmd = ["cargo", "build", "--quiet", "--release", "--manifest-path", str(rust_runner / "Cargo.toml")] + rust_build_cmd = [ + "cargo", + "build", + "--quiet", + "--release", + "--manifest-path", + str(rust_runner / "Cargo.toml"), + ] if os.environ.get("ANTLR_PERF_DUMP"): rust_build_cmd.extend(["--features", "perf-counters"]) - run(rust_build_cmd) + rust_build_env = None + if args.rust_native: + rust_build_env = os.environ.copy() + rustflags = rust_build_env.get("RUSTFLAGS", "") + rust_build_env["RUSTFLAGS"] = f"{rustflags} -C target-cpu=native".strip() + run(rust_build_cmd, env=rust_build_env) runners["rust-antlr"] = rust_runner / "target" / "release" / "parse-bench-rust-runner" if "python-antlr" in runtimes: runners["python-antlr"] = write_python_antlr_runner(work_dir, specs, py_gen) @@ -979,13 +1042,18 @@ def prepare_work( return runners -def clear_work_dir(work_dir: Path) -> None: +def clear_work_dir(work_dir: Path, runtime_root: Path) -> None: if not work_dir.exists(): return if not work_dir.is_dir(): raise SystemExit(f"Refusing to delete non-directory work path: {work_dir}") if work_dir == ROOT or work_dir in ROOT.parents: raise SystemExit(f"Refusing to delete project root or parent directory: {work_dir}") + if work_dir == runtime_root or work_dir in runtime_root.parents: + raise SystemExit( + "Refusing to delete work directory containing the runtime checkout: " + f"{work_dir} (runtime root: {runtime_root})" + ) shutil.rmtree(work_dir) @@ -1031,6 +1099,12 @@ def measure_fixture( fixture.language, "--input", str(fixture.abs_path), + ] + ) + if runtime == "rust-antlr": + cmd.extend(["--phase", args.phase]) + cmd.extend( + [ "--iters", str(args.iters), "--warmups", @@ -1101,8 +1175,12 @@ def write_json(results: list[Measurement], args: argparse.Namespace) -> None: "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), "iters": args.iters, "warmups": args.warmups, + "phase": args.phase, "rust_generated_only": args.rust_generated_only, - "repo": str(ROOT), + "repo": str(args.runtime_root), + "repo_commit": git_rev(args.runtime_root), + "rust_native": args.rust_native, + "rust_thin_lto": args.rust_thin_lto, "grammars_v4": { "path": str(args.grammars_v4), "commit": git_rev(args.grammars_v4), @@ -1122,10 +1200,14 @@ def write_markdown(results: list[Measurement], args: argparse.Namespace) -> None if result.runtime == "rust-antlr" } lines = [ - "# Parse Benchmark", + f"# {args.phase.title()} Benchmark", "", f"- Iterations: `{args.iters}`", f"- Warmups: `{args.warmups}`", + f"- Phase: `{args.phase}`", + f"- Runtime checkout: `{git_rev(args.runtime_root) or args.runtime_root}`", + f"- Native CPU: `{'yes' if args.rust_native else 'no'}`", + f"- ThinLTO / one codegen unit: `{'yes' if args.rust_thin_lto else 'no'}`", f"- Rust generated-only: `{'yes' if args.rust_generated_only else 'no'}`", f"- grammars-v4: `{git_rev(args.grammars_v4) or args.grammars_v4}`", "", @@ -1172,10 +1254,32 @@ def parse_args() -> argparse.Namespace: type=Path, default=Path(os.environ.get("GRAMMARS_V4", DEFAULT_GRAMMARS_V4)), ) + parser.add_argument( + "--runtime-root", + type=Path, + default=ROOT, + help="Runtime checkout to generate and link into the Rust benchmark runner.", + ) parser.add_argument("--work-dir", type=Path, default=ROOT / "target" / "parse-bench") parser.add_argument("--python", default=os.environ.get("PYTHON", sys.executable)) parser.add_argument("--languages", default="kotlin,csharp,java") parser.add_argument("--runtimes", default="rust-antlr,python-antlr,go-antlr,tree-sitter") + parser.add_argument( + "--phase", + choices=PHASES, + default="parse", + help="Measure complete parsing or lexing/token buffering only.", + ) + parser.add_argument( + "--rust-native", + action="store_true", + help="Build the Rust runner with -C target-cpu=native.", + ) + parser.add_argument( + "--rust-thin-lto", + action="store_true", + help="Build the Rust runner with ThinLTO and one codegen unit.", + ) parser.add_argument("--iters", type=int, default=10) parser.add_argument("--warmups", type=int, default=2) parser.add_argument("--quick", action="store_true", help="Use 3 timed iterations and 1 warmup.") @@ -1196,6 +1300,7 @@ def parse_args() -> argparse.Namespace: def normalize_paths(args: argparse.Namespace) -> None: args.antlr_jar = args.antlr_jar.expanduser().resolve() args.grammars_v4 = args.grammars_v4.expanduser().resolve() + args.runtime_root = args.runtime_root.expanduser().resolve() args.work_dir = args.work_dir.expanduser().resolve() if args.json is not None: args.json = args.json.expanduser().resolve() @@ -1216,10 +1321,13 @@ def main() -> int: languages = parse_csv(args.languages, LANGUAGES, "language") runtimes = set(parse_csv(args.runtimes, RUNTIMES, "runtime")) + if args.phase == "lex" and runtimes != {"rust-antlr"}: + raise SystemExit("--phase lex currently requires --runtimes rust-antlr") specs = [LANGUAGES[name] for name in languages] - fixtures = load_fixtures(set(languages)) + fixtures = load_fixtures(set(languages), args.phase) require_path(args.antlr_jar, "ANTLR jar") require_path(args.grammars_v4, "grammars-v4 checkout") + require_path(args.runtime_root / "Cargo.toml", "runtime Cargo manifest") ensure_python_dependencies(args.python, runtimes) runners = prepare_work(args, specs, runtimes) diff --git a/tools/parse-bench/test_run.py b/tools/parse-bench/test_run.py new file mode 100644 index 00000000..64c26ef2 --- /dev/null +++ b/tools/parse-bench/test_run.py @@ -0,0 +1,54 @@ +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +RUN_PATH = Path(__file__).with_name("run.py") +SPEC = importlib.util.spec_from_file_location("parse_bench_run", RUN_PATH) +assert SPEC is not None and SPEC.loader is not None +RUN = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = RUN +SPEC.loader.exec_module(RUN) + + +class ClearWorkDirTests(unittest.TestCase): + def test_rejects_runtime_root_and_ancestor(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp).resolve() + runtime_root = root / "checkout" + runtime_root.mkdir() + marker = runtime_root / "Cargo.toml" + marker.touch() + + with self.assertRaises(SystemExit): + RUN.clear_work_dir(runtime_root, runtime_root) + with self.assertRaises(SystemExit): + RUN.clear_work_dir(root, runtime_root) + + self.assertTrue(marker.exists()) + + def test_allows_disjoint_and_nested_work_directories(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp).resolve() + runtime_root = root / "checkout" + runtime_root.mkdir() + marker = runtime_root / "Cargo.toml" + marker.touch() + + sibling_work = root / "work" + sibling_work.mkdir() + RUN.clear_work_dir(sibling_work, runtime_root) + self.assertFalse(sibling_work.exists()) + self.assertTrue(marker.exists()) + + nested_work = runtime_root / "target" / "parse-bench" + nested_work.mkdir(parents=True) + RUN.clear_work_dir(nested_work, runtime_root) + self.assertFalse(nested_work.exists()) + self.assertTrue(marker.exists()) + + +if __name__ == "__main__": + unittest.main()