From 86b00a519cb87ca133ca657a448e37ed6e0a12d3 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 11 Jul 2026 15:01:22 +0100 Subject: [PATCH 1/4] gh-153568: Skip the expression precedence chain for single-token atoms A bare name or number followed by a token that cannot extend an expression is now built directly, avoiding a dozen rule invocations per atom. Rules declare the hook with the new (fastpath=function) flag next to (memo); the generator only emits the hook call and knows nothing about tokens or expressions. --- Grammar/python.gram | 4 +- InternalDocs/parser.md | 45 ++++++++++++++++++ ...-07-11-15-00-13.gh-issue-153568.atomfp.rst | 2 + Parser/parser.c | 8 ++++ Parser/pegen.c | 46 +++++++++++++++++++ Parser/pegen.h | 1 + .../peg_generator/pegen/c_generator_calls.py | 12 +++++ .../peg_generator/pegen/c_generator_model.py | 1 + .../peg_generator/pegen/c_generator_rules.py | 5 ++ Tools/peg_generator/pegen/grammar_parser.py | 11 ++++- Tools/peg_generator/pegen/metagrammar.gram | 1 + 11 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-07-11-15-00-13.gh-issue-153568.atomfp.rst diff --git a/Grammar/python.gram b/Grammar/python.gram index 36cd67231c18a96..f1800ca0597e77b 100644 --- a/Grammar/python.gram +++ b/Grammar/python.gram @@ -747,7 +747,7 @@ named_expression[expr_ty]: | invalid_named_expression | expression !':=' -disjunction[expr_ty] (memo): +disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path): | a=conjunction b=('or' c=conjunction { c })+ { _PyAST_BoolOp( Or, CHECK(asdl_expr_seq*, _PyPegen_seq_insert_in_front(p, a, b)), @@ -761,7 +761,7 @@ conjunction[expr_ty] (memo): EXTRA) } | inversion -inversion[expr_ty] (memo): +inversion[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path): | 'not' a=inversion { _PyAST_UnaryOp(Not, a, EXTRA) } | comparison diff --git a/InternalDocs/parser.md b/InternalDocs/parser.md index ac3d7449e03f3fa..3d9058bfc33f20f 100644 --- a/InternalDocs/parser.md +++ b/InternalDocs/parser.md @@ -616,6 +616,51 @@ actions. As with memoization, actions must not invalidate cached results. In particular, suffix actions must not move the parser before their starting mark, rewrite buffered input, or replace memo entries for earlier positions. +Fast-path hooks +--------------- + +Some rules are entered so often that even the bookkeeping of trying their +alternatives is expensive. Consider parsing the argument ``a`` in a call +like ``f(a, b)``: the argument is a full expression, so the parser descends +the whole operator precedence chain + +``` +expression -> disjunction -> conjunction -> inversion -> comparison + -> bitwise_or -> bitwise_xor -> bitwise_and -> shift_expr -> sum + -> term -> factor -> power -> await_primary -> primary -> atom +``` + +before it can produce the ``Name`` node for ``a``: around fourteen rule +invocations, each paying its own C-stack check and memoization lookups, to +consume a single token. But since the following token is ``,``, which +cannot continue any binary or postfix expression, that outcome is already +known after peeking at two tokens. + +For cases like this a rule can declare a hand-written C hook with the +``fastpath`` flag, next to where ``memo`` goes: + +``` +disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path): +``` + +The generator calls the hook on rule entry, before any alternative is tried: + +```c +if (_PyPegen_atom_fast_path(p, &_res)) { + p->level--; + return _res; +} +``` + +The hook receives the parser and a pointer to the rule's result variable and +returns 1 if it handled the parse (storing its result, which can be ``NULL`` +to signal failure with ``p->error_indicator`` set) or 0 to fall through to +the rule's normal alternatives. The generator knows nothing about what the +hook does: all parsing logic lives in the hook itself, next to the other +token helpers in [`Parser/pegen.c`](../Parser/pegen.c). A hook must behave +exactly like the rule it accelerates, including which tokens it fills, +because error reporting depends on the number of tokens read (``p->fill``). + Automatic variables ------------------- diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-11-15-00-13.gh-issue-153568.atomfp.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-11-15-00-13.gh-issue-153568.atomfp.rst new file mode 100644 index 000000000000000..32ee3f72d6ab059 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-11-15-00-13.gh-issue-153568.atomfp.rst @@ -0,0 +1,2 @@ +Speed up parsing of simple expressions by recognizing single-token atoms +without descending the full precedence rule chain. diff --git a/Parser/parser.c b/Parser/parser.c index 76ff308cfc9394f..fd94f979d0e8681 100644 --- a/Parser/parser.c +++ b/Parser/parser.c @@ -12299,6 +12299,10 @@ disjunction_rule(Parser *p) return NULL; } expr_ty _res = NULL; + if (_PyPegen_atom_fast_path(p, &_res)) { + p->level--; + return _res; + } if (_PyPegen_is_memoized(p, disjunction_type, &_res)) { p->level--; return _res; @@ -12479,6 +12483,10 @@ inversion_rule(Parser *p) return NULL; } expr_ty _res = NULL; + if (_PyPegen_atom_fast_path(p, &_res)) { + p->level--; + return _res; + } if (_PyPegen_is_memoized(p, inversion_type, &_res)) { p->level--; return _res; diff --git a/Parser/pegen.c b/Parser/pegen.c index 9643426fa74c885..fe6b4b5623f9e21 100644 --- a/Parser/pegen.c +++ b/Parser/pegen.c @@ -327,6 +327,52 @@ _resize_tokens_array(Parser *p) { return 0; } +// Fast path attached to the expression precedence chain's entry rules via +// the (fastpath=function) rule flag in the grammar. A bare NAME/NUMBER +// atom followed by a token that cannot start or continue a binary/postfix +// expression is a complete expression, so it is built directly instead of +// descending the whole chain. Returns 1 if it produced a result (stored +// in *result), 0 to fall through to the rule's alternatives. The second +// token is only examined when the first is NAME/NUMBER: the chain fills +// it too in that case, so error reporting (which keys off p->fill) +// observes an identical token fill state. +int +_PyPegen_atom_fast_path(Parser *p, void *result) +{ + if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) { + p->error_indicator = 1; + *(void **)result = NULL; + return 1; + } + Token *t = p->tokens[p->mark]; + if (t->type != NAME && t->type != NUMBER) { + return 0; + } + if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) { + p->error_indicator = 1; + *(void **)result = NULL; + return 1; + } + switch (p->tokens[p->mark + 1]->type) { + case COMMA: + case RPAR: + case RSQB: + case RBRACE: + case COLON: + case NEWLINE: + case SEMI: + case EQUAL: + case ENDMARKER: + break; + default: + return 0; + } + expr_ty res = (t->type == NAME) + ? _PyPegen_name_token(p) : _PyPegen_number_token(p); + *(void **)result = res; + return 1; +} + int _PyPegen_fill_token(Parser *p) { diff --git a/Parser/pegen.h b/Parser/pegen.h index 95ad2c9b7c0a843..9a5c3cf64aa703a 100644 --- a/Parser/pegen.h +++ b/Parser/pegen.h @@ -195,6 +195,7 @@ Token *_PyPegen_get_last_nonnwhitespace_token(Parser *); int _PyPegen_fill_token(Parser *p); // Return -1 on failure, leaving the buffer and capacity unchanged. int _PyPegen_grow_loop_buffer(void ***buffer, Py_ssize_t *capacity); +int _PyPegen_atom_fast_path(Parser *p, void *result); expr_ty _PyPegen_name_token(Parser *p); expr_ty _PyPegen_number_token(Parser *p); void *_PyPegen_string_token(Parser *p); diff --git a/Tools/peg_generator/pegen/c_generator_calls.py b/Tools/peg_generator/pegen/c_generator_calls.py index 474e65fd04793c1..7e35035b5195bc1 100644 --- a/Tools/peg_generator/pegen/c_generator_calls.py +++ b/Tools/peg_generator/pegen/c_generator_calls.py @@ -409,6 +409,17 @@ def candidate(alt: Alt) -> Rule | None: i = j self._prefixes[rule.name] = tuple(prefixes) + def _rule_fast_path(self, rule: Rule) -> str | None: + for flag in rule.flags: + name, _, func = flag.partition("=") + if name == "fastpath": + if not func: + raise ValueError( + f"rule {rule.name!r}: fastpath flag needs a function" + ) + return func + return None + def prepare_rule( self, rule: Rule, *, skip_actions: bool = False, shared: bool = False ) -> CRule: @@ -427,6 +438,7 @@ def prepare_rule( left_recursive=rule.left_recursive, leader=rule.leader, memoize="memo" in rule.flags and not rule.left_recursive, + fastpath=self._rule_fast_path(rule), disable_invalid_rules=rule.name.endswith("without_invalid"), prefixes=self._prefixes.get(rule.name, ()), source=rule.source, diff --git a/Tools/peg_generator/pegen/c_generator_model.py b/Tools/peg_generator/pegen/c_generator_model.py index 61ffa91787ff46d..2c2298e3238519d 100644 --- a/Tools/peg_generator/pegen/c_generator_model.py +++ b/Tools/peg_generator/pegen/c_generator_model.py @@ -123,6 +123,7 @@ class CRule: left_recursive: bool leader: bool memoize: bool + fastpath: str | None disable_invalid_rules: bool prefixes: tuple[CPrefix, ...] = () source: pathlib.Path | None = None diff --git a/Tools/peg_generator/pegen/c_generator_rules.py b/Tools/peg_generator/pegen/c_generator_rules.py index 89d105039f9e36c..cc252ba30d291f0 100644 --- a/Tools/peg_generator/pegen/c_generator_rules.py +++ b/Tools/peg_generator/pegen/c_generator_rules.py @@ -176,6 +176,11 @@ def _emit_rule_body(self) -> None: self._emit_recursion_check() self._emit_error_check() self._print(f"{result_type} _res = NULL;") + if fastpath := self._rule.fastpath: + self._print(f"if ({fastpath}(p, &_res)) {{") + with self._indent(): + self._returns.emit("_res") + self._print("}") if memoize: self._print(f"if (_PyPegen_is_memoized(p, {signature.name}_type, &_res)) {{") with self._indent(): diff --git a/Tools/peg_generator/pegen/grammar_parser.py b/Tools/peg_generator/pegen/grammar_parser.py index 4fa2739270773fe..a70de352944cc0f 100644 --- a/Tools/peg_generator/pegen/grammar_parser.py +++ b/Tools/peg_generator/pegen/grammar_parser.py @@ -235,8 +235,17 @@ def flags(self) -> Optional[frozenset [str]]: @memoize def flag(self) -> Optional[str]: - # flag: NAME + # flag: NAME '=' NAME | NAME mark = self._mark() + if ( + (a := self.name()) + and + (literal := self.expect('=')) + and + (b := self.name()) + ): + return a . string + "=" + b . string + self._reset(mark) if ( (name := self.name()) ): diff --git a/Tools/peg_generator/pegen/metagrammar.gram b/Tools/peg_generator/pegen/metagrammar.gram index cae91ab9c4165bb..93864a2b43fe206 100644 --- a/Tools/peg_generator/pegen/metagrammar.gram +++ b/Tools/peg_generator/pegen/metagrammar.gram @@ -64,6 +64,7 @@ flags[frozenset[str]]: | '(' a=','.flag+ ')' { frozenset(a) } flag[str]: + | a=NAME '=' b=NAME { a.string + "=" + b.string } | NAME { name.string } alts[Rhs]: From 071ed26e75dc9bca764f03d9692a0e147ea1c6c4 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Fri, 23 Oct 2026 11:53:27 -0700 Subject: [PATCH 2/4] Update Parser/pegen.c Co-authored-by: Lysandros Nikolaou --- Parser/pegen.c | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Parser/pegen.c b/Parser/pegen.c index fe6b4b5623f9e21..5ddd7a9d01e2e92 100644 --- a/Parser/pegen.c +++ b/Parser/pegen.c @@ -332,10 +332,7 @@ _resize_tokens_array(Parser *p) { // atom followed by a token that cannot start or continue a binary/postfix // expression is a complete expression, so it is built directly instead of // descending the whole chain. Returns 1 if it produced a result (stored -// in *result), 0 to fall through to the rule's alternatives. The second -// token is only examined when the first is NAME/NUMBER: the chain fills -// it too in that case, so error reporting (which keys off p->fill) -// observes an identical token fill state. +// in *result), 0 to fall through to the rule's alternatives. int _PyPegen_atom_fast_path(Parser *p, void *result) { From a431447a52cfcfa88776466676f5c9008e2277ea Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Fri, 23 Oct 2026 20:00:22 +0100 Subject: [PATCH 3/4] gh-153568: Parse rule flags in the Rule constructor Expose memo and fastpath attributes for the C generator and simplify the atom fast-path comment. --- .../test_peg_generator/test_c_generator.py | 4 +- Lib/test/test_peg_generator/test_c_parser.py | 41 +++++++++++++++++++ Lib/test/test_peg_generator/test_pegen.py | 29 +++++++++++-- Parser/pegen.c | 4 +- .../peg_generator/pegen/c_generator_calls.py | 17 ++------ Tools/peg_generator/pegen/grammar.py | 11 +++++ 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_peg_generator/test_c_generator.py b/Lib/test/test_peg_generator/test_c_generator.py index 6d5cad8618bb1b1..b2de91577bca338 100644 --- a/Lib/test/test_peg_generator/test_c_generator.py +++ b/Lib/test/test_peg_generator/test_c_generator.py @@ -63,7 +63,7 @@ def test_parser_plan_does_not_depend_on_compilation_state(self): @modulename 'sample' @bytecode '1' start[mod_ty]: expr_without_invalid 'pass' "zsoft" "asoft" ('bb' | 'aa')* ENDMARKER - expr_without_invalid[expr_ty] (memo): name=expr [NUMBER] { name } + expr_without_invalid[expr_ty] (memo, fastpath=custom_fastpath): name=expr [NUMBER] { name } expr[expr_ty]: expr '+' NAME | NAME """) generator.debug = True @@ -86,6 +86,8 @@ def test_parser_plan_does_not_depend_on_compilation_state(self): rule.name = "changed" rule.type = "changed_type" rule.flags = frozenset() + rule.memo = False + rule.fastpath = None rule.rhs.alts[0].action = "changed_action" rule.rhs.alts.clear() generator.rules.clear() diff --git a/Lib/test/test_peg_generator/test_c_parser.py b/Lib/test/test_peg_generator/test_c_parser.py index dbe8ccc92014386..d67cd63fb1a3065 100644 --- a/Lib/test/test_peg_generator/test_c_parser.py +++ b/Lib/test/test_peg_generator/test_c_parser.py @@ -464,6 +464,47 @@ def test_rule_cleanup(self) -> None: """ self.run_test(grammar_source, test_source) + def test_rule_fastpath(self) -> None: + grammar_source = """ + @subheader ''' + #define CHECK_INVALID(expected) \\ + (assert(p->call_invalid_rules == (expected)), _PyPegen_dummy_name(p)) + + static int + number_fastpath(Parser *p, void *result) + { + assert(p->call_invalid_rules == 0); + expr_ty number = _PyPegen_number_token(p); + if (number != NULL) { + *(void **)result = number; + return 1; + } + if (_PyPegen_expect_token(p, LPAR)) { + p->error_indicator = 1; + PyErr_SetString(PyExc_ValueError, "fastpath error"); + } + if (p->error_indicator) { + *(void **)result = NULL; + return 1; + } + return 0; + } + ''' + start: enable value_without_invalid after NEWLINE ENDMARKER + enable: 'enable' { (p->call_invalid_rules = 1, _PyPegen_dummy_name(p)) } + value_without_invalid (memo, fastpath=number_fastpath): NAME { CHECK_INVALID(0) } + after: NAME { CHECK_INVALID(1) } + """ + test_source = """ + self.check_input_strings_for_grammar( + valid_cases=['enable 42 tail', 'enable name tail'], + invalid_cases=['enable + tail', 'enable 42'], + ) + with self.assertRaisesRegex(ValueError, 'fastpath error'): + parse.parse_string('enable ( tail', mode=0) + """ + self.run_test(grammar_source, test_source) + def test_left_recursive_rule_cleanup(self) -> None: grammar_source = """ @subheader ''' diff --git a/Lib/test/test_peg_generator/test_pegen.py b/Lib/test/test_peg_generator/test_pegen.py index 88fe54d21d80bf3..073bdc36ae631d9 100644 --- a/Lib/test/test_peg_generator/test_pegen.py +++ b/Lib/test/test_peg_generator/test_pegen.py @@ -1181,32 +1181,53 @@ def test_rule_flags(self) -> None: # Test memo-only rule simple_rule = rules['simple_rule'] - self.assertTrue('memo' in simple_rule.flags, + self.assertTrue(simple_rule.memo, "simple_rule should have memo") self.assertEqual(simple_rule.flags, frozenset(['memo']), f"simple_rule flags should be {'memo'}, got {simple_rule.flags}") # Test multi-flag rule multi_flag_rule = rules['multi_flag_rule'] - self.assertTrue('memo' in simple_rule.flags, + self.assertTrue(multi_flag_rule.memo, "multi_flag_rule should have memo") self.assertEqual(multi_flag_rule.flags, frozenset({'memo', 'custom', 'test'}), f"multi_flag_rule flags should contain memo, custom, test, got {multi_flag_rule.flags}") # Test single custom flag rule single_custom_rule = rules['single_custom_flag'] - self.assertFalse('memo' not in simple_rule.flags, + self.assertFalse(single_custom_rule.memo, "single_custom_flag should not have memo") self.assertEqual(single_custom_rule.flags, frozenset(['custom']), f"single_custom_flag flags should be {'custom'}, got {single_custom_rule.flags}") # Test no flags rule no_flags_rule = rules['no_flags_rule'] - self.assertFalse('memo' not in simple_rule.flags, + self.assertFalse(no_flags_rule.memo, "no_flags_rule should not have memo") self.assertEqual(no_flags_rule.flags, frozenset(), f"no_flags_rule flags should be the empty set, got {no_flags_rule.flags}") + for rule in rules.values(): + self.assertIsNone(rule.fastpath) + + def test_rule_fastpath_flags(self) -> None: + for rule_type in ("", "[expr_ty]"): + for memo in (False, True): + with self.subTest(rule_type=rule_type, memo=memo): + flags = "fastpath=custom_fastpath" + if memo: + flags = "memo, " + flags + grammar_source = f"start{rule_type} ({flags}): NAME\n" + rule = parse_string(grammar_source, GrammarParser).rules["start"] + self.assertEqual(rule.fastpath, "custom_fastpath") + self.assertIs(rule.memo, memo) + + def test_rule_fastpath_requires_function(self) -> None: + with self.assertRaisesRegex( + ValueError, "rule 'start': fastpath flag needs a function" + ): + parse_string("start (fastpath): NAME\n", GrammarParser) + class TestGrammarIncludes(unittest.TestCase): def setUp(self) -> None: diff --git a/Parser/pegen.c b/Parser/pegen.c index 5ddd7a9d01e2e92..73c0192c26003f7 100644 --- a/Parser/pegen.c +++ b/Parser/pegen.c @@ -327,8 +327,8 @@ _resize_tokens_array(Parser *p) { return 0; } -// Fast path attached to the expression precedence chain's entry rules via -// the (fastpath=function) rule flag in the grammar. A bare NAME/NUMBER +// Fast path attached to the expression precedence chain's entry rules. +// A bare NAME/NUMBER // atom followed by a token that cannot start or continue a binary/postfix // expression is a complete expression, so it is built directly instead of // descending the whole chain. Returns 1 if it produced a result (stored diff --git a/Tools/peg_generator/pegen/c_generator_calls.py b/Tools/peg_generator/pegen/c_generator_calls.py index 7e35035b5195bc1..ffc4a3c01aabf71 100644 --- a/Tools/peg_generator/pegen/c_generator_calls.py +++ b/Tools/peg_generator/pegen/c_generator_calls.py @@ -381,7 +381,7 @@ def candidate(alt: Alt) -> Rule | None: rule = original_rules.get(alt.items[0].item.value) if rule is None or rule.name not in consuming: return None - if ("memo" in rule.flags and not rule.left_recursive) or ( + if (rule.memo and not rule.left_recursive) or ( rule.left_recursive and rule.leader ): return rule @@ -409,17 +409,6 @@ def candidate(alt: Alt) -> Rule | None: i = j self._prefixes[rule.name] = tuple(prefixes) - def _rule_fast_path(self, rule: Rule) -> str | None: - for flag in rule.flags: - name, _, func = flag.partition("=") - if name == "fastpath": - if not func: - raise ValueError( - f"rule {rule.name!r}: fastpath flag needs a function" - ) - return func - return None - def prepare_rule( self, rule: Rule, *, skip_actions: bool = False, shared: bool = False ) -> CRule: @@ -437,8 +426,8 @@ def prepare_rule( ), left_recursive=rule.left_recursive, leader=rule.leader, - memoize="memo" in rule.flags and not rule.left_recursive, - fastpath=self._rule_fast_path(rule), + memoize=rule.memo and not rule.left_recursive, + fastpath=rule.fastpath, disable_invalid_rules=rule.name.endswith("without_invalid"), prefixes=self._prefixes.get(rule.name, ()), source=rule.source, diff --git a/Tools/peg_generator/pegen/grammar.py b/Tools/peg_generator/pegen/grammar.py index a71a828485e6b59..b64fdbf9120b702 100644 --- a/Tools/peg_generator/pegen/grammar.py +++ b/Tools/peg_generator/pegen/grammar.py @@ -90,6 +90,17 @@ def __init__( self.type = type self.rhs = rhs self.flags = flags or frozenset() + self.memo = "memo" in self.flags + self.fastpath: str | None = None + for flag in self.flags: + name, _, function = flag.partition("=") + if name == "fastpath": + if not function: + raise ValueError( + f"rule {self.name!r}: fastpath flag needs a function" + ) + self.fastpath = function + break self.kind = kind # The included grammar file that defines this rule, relative to the # root grammar file's directory, or None for the root grammar file itself. From ef620c0362b808f832fa036f801c0f280a6fa804 Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Fri, 23 Oct 2026 12:25:56 -0700 Subject: [PATCH 4/4] Update Parser/pegen.c --- Parser/pegen.c | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Parser/pegen.c b/Parser/pegen.c index 73c0192c26003f7..a49651e6dde2aeb 100644 --- a/Parser/pegen.c +++ b/Parser/pegen.c @@ -328,11 +328,12 @@ _resize_tokens_array(Parser *p) { } // Fast path attached to the expression precedence chain's entry rules. -// A bare NAME/NUMBER -// atom followed by a token that cannot start or continue a binary/postfix -// expression is a complete expression, so it is built directly instead of -// descending the whole chain. Returns 1 if it produced a result (stored -// in *result), 0 to fall through to the rule's alternatives. +// A bare NAME/NUMBER atom followed by a token that cannot start or +// continue a binary/postfix expression is a complete expression, so it is +// built directly instead of descending the whole chain. +// +// Returns 1 if it produced a result (stored in *result), 0 to fall through to +// the rule's alternatives. int _PyPegen_atom_fast_path(Parser *p, void *result) {