From 61277c2276dda28b835708a2085526f825c7c4f5 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 11 Jul 2026 15:01:22 +0100 Subject: [PATCH] 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 + Tools/peg_generator/pegen/c_generator.py | 34 ++++++++++++++ Tools/peg_generator/pegen/grammar_parser.py | 11 ++++- Tools/peg_generator/pegen/metagrammar.gram | 1 + 9 files changed, 149 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 ac1b4a64f38c75..a1346ccd80d7e9 100644 --- a/Grammar/python.gram +++ b/Grammar/python.gram @@ -761,7 +761,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)), @@ -775,7 +775,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 1bb4cdea5439f1..873a77c517a2db 100644 --- a/InternalDocs/parser.md +++ b/InternalDocs/parser.md @@ -563,6 +563,51 @@ in the generated C parse code that allows to measure how much each rule uses memoization (check the [`Parser/pegen.c`](../Parser/pegen.c) file for more information) but it needs to be manually activated. +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 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 4f4fd3ed46d1f0..912e82a1f9c9c1 100644 --- a/Parser/parser.c +++ b/Parser/parser.c @@ -12508,6 +12508,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; @@ -12684,6 +12688,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 165dcb9f995095..e73bb2eca56a3d 100644 --- a/Parser/pegen.c +++ b/Parser/pegen.c @@ -241,6 +241,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 cbf44ba474fa39..f65a13edf0fd7a 100644 --- a/Parser/pegen.h +++ b/Parser/pegen.h @@ -159,6 +159,7 @@ expr_ty _PyPegen_soft_keyword_token(Parser *p); expr_ty _PyPegen_fstring_middle_token(Parser* p); Token *_PyPegen_get_last_nonnwhitespace_token(Parser *); int _PyPegen_fill_token(Parser *p); +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.py b/Tools/peg_generator/pegen/c_generator.py index d9236dfb22835b..36c478fe1732d3 100644 --- a/Tools/peg_generator/pegen/c_generator.py +++ b/Tools/peg_generator/pegen/c_generator.py @@ -598,6 +598,34 @@ def _set_up_rule_memoization(self, node: Rule, result_type: str) -> None: def _should_memoize(self, node: Rule) -> bool: return "memo" in node.flags and not node.left_recursive + def _rule_fast_path(self, node: Rule) -> str | None: + """Return the C fast-path hook declared by a (fastpath=) rule flag. + + The hook is called on rule entry and returns 1 if it handled the + parse (storing its result), 0 to fall through to the rule's + alternatives. For example, given + + disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path): + + the disjunction rule body starts with + + if (_PyPegen_atom_fast_path(p, &_res)) { + p->level--; + return _res; + } + + See "Fast-path hooks" in InternalDocs/parser.md. + """ + for flag in node.flags: + name, _, func = flag.partition("=") + if name == "fastpath": + if not func: + raise ValueError( + f"rule {node.name!r}: fastpath flag needs a function" + ) + return func + return None + def _handle_default_rule_body(self, node: Rule, rhs: Rhs, result_type: str) -> None: memoize = self._should_memoize(node) @@ -605,6 +633,12 @@ def _handle_default_rule_body(self, node: Rule, rhs: Rhs, result_type: str) -> N self.add_level() self._check_for_errors() self.print(f"{result_type} _res = NULL;") + fastpath = self._rule_fast_path(node) + if fastpath: + self.print(f"if ({fastpath}(p, &_res)) {{") + with self.indent(): + self.add_return("_res") + self.print("}") if memoize: self.print(f"if (_PyPegen_is_memoized(p, {node.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 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]: