Skip to content

Commit 5616907

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 e5fbabb commit 5616907

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
@@ -754,7 +754,7 @@ named_expression[expr_ty]:
754754
| invalid_named_expression
755755
| expression !':='
756756

757-
disjunction[expr_ty] (memo):
757+
disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
758758
| a=conjunction b=('or' c=conjunction { c })+ { _PyAST_BoolOp(
759759
Or,
760760
CHECK(asdl_expr_seq*, _PyPegen_seq_insert_in_front(p, a, b)),
@@ -768,7 +768,7 @@ conjunction[expr_ty] (memo):
768768
EXTRA) }
769769
| inversion
770770

771-
inversion[expr_ty] (memo):
771+
inversion[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
772772
| 'not' a=inversion { _PyAST_UnaryOp(Not, a, EXTRA) }
773773
| comparison
774774

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
@@ -296,6 +296,52 @@ _resize_tokens_array(Parser *p) {
296296
return 0;
297297
}
298298

299+
// Fast path attached to the expression precedence chain's entry rules via
300+
// the (fastpath=function) rule flag in the grammar. A bare NAME/NUMBER
301+
// atom followed by a token that cannot start or continue a binary/postfix
302+
// expression is a complete expression, so it is built directly instead of
303+
// descending the whole chain. Returns 1 if it produced a result (stored
304+
// in *result), 0 to fall through to the rule's alternatives. The second
305+
// token is only examined when the first is NAME/NUMBER: the chain fills
306+
// it too in that case, so error reporting (which keys off p->fill)
307+
// observes an identical token fill state.
308+
int
309+
_PyPegen_atom_fast_path(Parser *p, void *result)
310+
{
311+
if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) {
312+
p->error_indicator = 1;
313+
*(void **)result = NULL;
314+
return 1;
315+
}
316+
Token *t = p->tokens[p->mark];
317+
if (t->type != NAME && t->type != NUMBER) {
318+
return 0;
319+
}
320+
if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) {
321+
p->error_indicator = 1;
322+
*(void **)result = NULL;
323+
return 1;
324+
}
325+
switch (p->tokens[p->mark + 1]->type) {
326+
case COMMA:
327+
case RPAR:
328+
case RSQB:
329+
case RBRACE:
330+
case COLON:
331+
case NEWLINE:
332+
case SEMI:
333+
case EQUAL:
334+
case ENDMARKER:
335+
break;
336+
default:
337+
return 0;
338+
}
339+
expr_ty res = (t->type == NAME)
340+
? _PyPegen_name_token(p) : _PyPegen_number_token(p);
341+
*(void **)result = res;
342+
return 1;
343+
}
344+
299345
int
300346
_PyPegen_fill_token(Parser *p)
301347
{

Parser/pegen.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ expr_ty _PyPegen_soft_keyword_token(Parser *p);
189189
expr_ty _PyPegen_fstring_middle_token(Parser* p);
190190
Token *_PyPegen_get_last_nonnwhitespace_token(Parser *);
191191
int _PyPegen_fill_token(Parser *p);
192+
int _PyPegen_atom_fast_path(Parser *p, void *result);
192193
expr_ty _PyPegen_name_token(Parser *p);
193194
expr_ty _PyPegen_number_token(Parser *p);
194195
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
@@ -602,13 +602,47 @@ def _set_up_rule_memoization(self, node: Rule, result_type: str) -> None:
602602
def _should_memoize(self, node: Rule) -> bool:
603603
return "memo" in node.flags and not node.left_recursive
604604

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

608636
with self.indent():
609637
self.add_level()
610638
self._check_for_errors()
611639
self.print(f"{result_type} _res = NULL;")
640+
fastpath = self._rule_fast_path(node)
641+
if fastpath:
642+
self.print(f"if ({fastpath}(p, &_res)) {{")
643+
with self.indent():
644+
self.add_return("_res")
645+
self.print("}")
612646
if memoize:
613647
self.print(f"if (_PyPegen_is_memoized(p, {node.name}_type, &_res)) {{")
614648
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)