diff --git a/Grammar/python.gram b/Grammar/python.gram index 36cd67231c18a9..f1800ca0597e77 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 ac3d7449e03f3f..3d9058bfc33f20 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/Lib/test/test_peg_generator/test_c_generator.py b/Lib/test/test_peg_generator/test_c_generator.py index 6d5cad8618bb1b..b2de91577bca33 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 dbe8ccc9201438..d67cd63fb1a306 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 88fe54d21d80bf..073bdc36ae631d 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/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 00000000000000..32ee3f72d6ab05 --- /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 76ff308cfc9394..fd94f979d0e868 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 9643426fa74c88..a49651e6dde2ae 100644 --- a/Parser/pegen.c +++ b/Parser/pegen.c @@ -327,6 +327,50 @@ _resize_tokens_array(Parser *p) { return 0; } +// 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. +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 95ad2c9b7c0a84..9a5c3cf64aa703 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 474e65fd04793c..ffc4a3c01aabf7 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 @@ -426,7 +426,8 @@ def prepare_rule( ), left_recursive=rule.left_recursive, leader=rule.leader, - memoize="memo" in rule.flags and not rule.left_recursive, + 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/c_generator_model.py b/Tools/peg_generator/pegen/c_generator_model.py index 61ffa91787ff46..2c2298e3238519 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 89d105039f9e36..cc252ba30d291f 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.py b/Tools/peg_generator/pegen/grammar.py index a71a828485e6b5..b64fdbf9120b70 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. diff --git a/Tools/peg_generator/pegen/grammar_parser.py b/Tools/peg_generator/pegen/grammar_parser.py index 4fa2739270773f..a70de352944cc0 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 cae91ab9c4165b..93864a2b43fe20 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]: