Skip to content

Commit b3a8d8f

Browse files
committed
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.
1 parent 106eb53 commit b3a8d8f

9 files changed

Lines changed: 149 additions & 3 deletions

File tree

Grammar/python.gram

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,7 @@ named_expression[expr_ty]:
761761
| invalid_named_expression
762762
| expression !':='
763763

764-
disjunction[expr_ty] (memo):
764+
disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
765765
| a=conjunction b=('or' c=conjunction { c })+ { _PyAST_BoolOp(
766766
Or,
767767
CHECK(asdl_expr_seq*, _PyPegen_seq_insert_in_front(p, a, b)),
@@ -775,7 +775,7 @@ conjunction[expr_ty] (memo):
775775
EXTRA) }
776776
| inversion
777777

778-
inversion[expr_ty] (memo):
778+
inversion[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
779779
| 'not' a=inversion { _PyAST_UnaryOp(Not, a, EXTRA) }
780780
| comparison
781781

InternalDocs/parser.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,51 @@ in the generated C parse code that allows to measure how much each rule uses
563563
memoization (check the [`Parser/pegen.c`](../Parser/pegen.c)
564564
file for more information) but it needs to be manually activated.
565565

566+
Fast-path hooks
567+
---------------
568+
569+
Some rules are entered so often that even the bookkeeping of trying their
570+
alternatives is expensive. Consider parsing the argument ``a`` in a call
571+
like ``f(a, b)``: the argument is a full expression, so the parser descends
572+
the whole operator precedence chain
573+
574+
```
575+
expression -> disjunction -> conjunction -> inversion -> comparison
576+
-> bitwise_or -> bitwise_xor -> bitwise_and -> shift_expr -> sum
577+
-> term -> factor -> power -> await_primary -> primary -> atom
578+
```
579+
580+
before it can produce the ``Name`` node for ``a``: around fourteen rule
581+
invocations, each paying its own C-stack check and memoization lookups, to
582+
consume a single token. But since the following token is ``,``, which
583+
cannot continue any binary or postfix expression, that outcome is already
584+
known after peeking at two tokens.
585+
586+
For cases like this a rule can declare a hand-written C hook with the
587+
``fastpath`` flag, next to where ``memo`` goes:
588+
589+
```
590+
disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
591+
```
592+
593+
The generator calls the hook on rule entry, before any alternative is tried:
594+
595+
```c
596+
if (_PyPegen_atom_fast_path(p, &_res)) {
597+
p->level--;
598+
return _res;
599+
}
600+
```
601+
602+
The hook receives the parser and a pointer to the rule's result variable and
603+
returns 1 if it handled the parse (storing its result, which can be ``NULL``
604+
to signal failure with ``p->error_indicator`` set) or 0 to fall through to
605+
the rule's normal alternatives. The generator knows nothing about what the
606+
hook does: all parsing logic lives in the hook itself, next to the other
607+
token helpers in [`Parser/pegen.c`](../Parser/pegen.c). A hook must behave
608+
exactly like the rule it accelerates, including which tokens it fills,
609+
because error reporting depends on the number of tokens read (``p->fill``).
610+
566611
Automatic variables
567612
-------------------
568613

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Speed up parsing of simple expressions by recognizing single-token atoms
2+
without descending the full precedence rule chain.

Parser/parser.c

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Parser/pegen.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,52 @@ _resize_tokens_array(Parser *p) {
241241
return 0;
242242
}
243243

244+
// Fast path attached to the expression precedence chain's entry rules via
245+
// the @fastpath meta in the grammar. A bare NAME/NUMBER atom followed by a
246+
// token that cannot start or continue a binary/postfix expression is a
247+
// complete expression, so it is built directly instead of descending the
248+
// whole chain. Returns 1 if it produced a result (stored in *result), 0 to
249+
// fall through to the rule's alternatives. The second token is only
250+
// examined when the first is NAME/NUMBER: the precedence chain fills it
251+
// too in that case, so error reporting (which keys off p->fill) observes
252+
// an identical token fill state.
253+
int
254+
_PyPegen_atom_fast_path(Parser *p, void *result)
255+
{
256+
if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) {
257+
p->error_indicator = 1;
258+
*(void **)result = NULL;
259+
return 1;
260+
}
261+
Token *t = p->tokens[p->mark];
262+
if (t->type != NAME && t->type != NUMBER) {
263+
return 0;
264+
}
265+
if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) {
266+
p->error_indicator = 1;
267+
*(void **)result = NULL;
268+
return 1;
269+
}
270+
switch (p->tokens[p->mark + 1]->type) {
271+
case COMMA:
272+
case RPAR:
273+
case RSQB:
274+
case RBRACE:
275+
case COLON:
276+
case NEWLINE:
277+
case SEMI:
278+
case EQUAL:
279+
case ENDMARKER:
280+
break;
281+
default:
282+
return 0;
283+
}
284+
expr_ty res = (t->type == NAME)
285+
? _PyPegen_name_token(p) : _PyPegen_number_token(p);
286+
*(void **)result = res;
287+
return 1;
288+
}
289+
244290
int
245291
_PyPegen_fill_token(Parser *p)
246292
{

Parser/pegen.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ expr_ty _PyPegen_soft_keyword_token(Parser *p);
159159
expr_ty _PyPegen_fstring_middle_token(Parser* p);
160160
Token *_PyPegen_get_last_nonnwhitespace_token(Parser *);
161161
int _PyPegen_fill_token(Parser *p);
162+
int _PyPegen_atom_fast_path(Parser *p, void *result);
162163
expr_ty _PyPegen_name_token(Parser *p);
163164
expr_ty _PyPegen_number_token(Parser *p);
164165
void *_PyPegen_string_token(Parser *p);

Tools/peg_generator/pegen/c_generator.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,13 +598,47 @@ def _set_up_rule_memoization(self, node: Rule, result_type: str) -> None:
598598
def _should_memoize(self, node: Rule) -> bool:
599599
return "memo" in node.flags and not node.left_recursive
600600

601+
def _rule_fast_path(self, node: Rule) -> str | None:
602+
"""Return the C fast-path hook declared by a (fastpath=<function>) rule flag.
603+
604+
The hook is called on rule entry and returns 1 if it handled the
605+
parse (storing its result), 0 to fall through to the rule's
606+
alternatives. For example, given
607+
608+
disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
609+
610+
the disjunction rule body starts with
611+
612+
if (_PyPegen_atom_fast_path(p, &_res)) {
613+
p->level--;
614+
return _res;
615+
}
616+
617+
See "Fast-path hooks" in InternalDocs/parser.md.
618+
"""
619+
for flag in node.flags:
620+
name, _, func = flag.partition("=")
621+
if name == "fastpath":
622+
if not func:
623+
raise ValueError(
624+
f"rule {node.name!r}: fastpath flag needs a function"
625+
)
626+
return func
627+
return None
628+
601629
def _handle_default_rule_body(self, node: Rule, rhs: Rhs, result_type: str) -> None:
602630
memoize = self._should_memoize(node)
603631

604632
with self.indent():
605633
self.add_level()
606634
self._check_for_errors()
607635
self.print(f"{result_type} _res = NULL;")
636+
fastpath = self._rule_fast_path(node)
637+
if fastpath:
638+
self.print(f"if ({fastpath}(p, &_res)) {{")
639+
with self.indent():
640+
self.add_return("_res")
641+
self.print("}")
608642
if memoize:
609643
self.print(f"if (_PyPegen_is_memoized(p, {node.name}_type, &_res)) {{")
610644
with self.indent():

Tools/peg_generator/pegen/grammar_parser.py

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Tools/peg_generator/pegen/metagrammar.gram

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ flags[frozenset[str]]:
6464
| '(' a=','.flag+ ')' { frozenset(a) }
6565

6666
flag[str]:
67+
| a=NAME '=' b=NAME { a.string + "=" + b.string }
6768
| NAME { name.string }
6869

6970
alts[Rhs]:

0 commit comments

Comments
 (0)