Skip to content

Commit 26282a7

Browse files
committed
gh-153568: Add fast paths for single-token atoms in the parser
A bare name or number followed by a token that cannot extend the current construct is now built directly, avoiding a dozen rule invocations per atom in expressions and skipping the failing attribute/subscript attempts for assignment targets. Rules declare their 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 26282a7

9 files changed

Lines changed: 216 additions & 4 deletions

File tree

Grammar/python.gram

Lines changed: 3 additions & 3 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

@@ -1133,7 +1133,7 @@ star_target[expr_ty] (memo):
11331133
_PyAST_Starred(CHECK(expr_ty, _PyPegen_set_expr_context(p, a, Store)), Store, EXTRA) }
11341134
| target_with_star_atom
11351135

1136-
target_with_star_atom[expr_ty] (memo):
1136+
target_with_star_atom[expr_ty] (memo, fastpath=_PyPegen_target_fast_path):
11371137
| a=t_primary '.' b=NAME !t_lookahead { _PyAST_Attribute(a, b->v.Name.id, Store, EXTRA) }
11381138
| a=t_primary '[' b=slices ']' !t_lookahead { _PyAST_Subscript(a, b, Store, EXTRA) }
11391139
| star_atom

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: 12 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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,113 @@ _resize_tokens_array(Parser *p) {
241241
return 0;
242242
}
243243

244+
// Shared skeleton of the (fastpath=...) rule hooks below: when the
245+
// current token is NAME (or NUMBER, if allowed), look one token ahead and
246+
// return the current token with the next token's type in *next_type.
247+
// Returns NULL when the fast path does not apply; a tokenizer failure sets
248+
// p->error_indicator, stores NULL in *result and reports the parse as
249+
// handled through *handled. The second token is only examined when the
250+
// first is NAME/NUMBER: the normal path fills it too in that case, so
251+
// error reporting (which keys off p->fill) observes an identical token
252+
// fill state.
253+
static Token *
254+
fast_path_atom_token(Parser *p, void *result, int allow_number,
255+
int *next_type, int *handled)
256+
{
257+
if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) {
258+
goto error;
259+
}
260+
Token *t = p->tokens[p->mark];
261+
if (t->type != NAME && !(allow_number && t->type == NUMBER)) {
262+
return NULL;
263+
}
264+
if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) {
265+
goto error;
266+
}
267+
*next_type = p->tokens[p->mark + 1]->type;
268+
return t;
269+
270+
error:
271+
p->error_indicator = 1;
272+
*(void **)result = NULL;
273+
*handled = 1;
274+
return NULL;
275+
}
276+
277+
// Fast path attached to the expression precedence chain's entry rules via
278+
// the (fastpath=function) rule flag in the grammar. A bare NAME/NUMBER
279+
// atom followed by a token that cannot start or continue a binary/postfix
280+
// expression is a complete expression, so it is built directly instead of
281+
// descending the whole chain. Returns 1 if it produced a result (stored
282+
// in *result), 0 to fall through to the rule's alternatives.
283+
int
284+
_PyPegen_atom_fast_path(Parser *p, void *result)
285+
{
286+
int next_type;
287+
int handled = 0;
288+
Token *t = fast_path_atom_token(p, result, 1, &next_type, &handled);
289+
if (t == NULL) {
290+
return handled;
291+
}
292+
switch (next_type) {
293+
case COMMA:
294+
case RPAR:
295+
case RSQB:
296+
case RBRACE:
297+
case COLON:
298+
case NEWLINE:
299+
case SEMI:
300+
case EQUAL:
301+
case ENDMARKER:
302+
break;
303+
default:
304+
return 0;
305+
}
306+
expr_ty res = (t->type == NAME)
307+
? _PyPegen_name_token(p) : _PyPegen_number_token(p);
308+
*(void **)result = res;
309+
return 1;
310+
}
311+
312+
// Fast path for assignment targets, attached to target_with_star_atom via
313+
// the (fastpath=function) rule flag: a bare NAME followed by a token that
314+
// cannot extend a target parses to that name in Store context, skipping
315+
// the t_primary attribute/subscript attempts. NAME only: literals must
316+
// keep failing through the normal path so "cannot assign to literal"
317+
// errors are unaffected.
318+
int
319+
_PyPegen_target_fast_path(Parser *p, void *result)
320+
{
321+
int next_type;
322+
int handled = 0;
323+
Token *t = fast_path_atom_token(p, result, 0, &next_type, &handled);
324+
if (t == NULL) {
325+
return handled;
326+
}
327+
switch (next_type) {
328+
case EQUAL:
329+
case COMMA:
330+
case RPAR:
331+
case RSQB:
332+
case COLON:
333+
case NEWLINE:
334+
break;
335+
default:
336+
return 0;
337+
}
338+
expr_ty name = _PyPegen_name_token(p);
339+
if (name == NULL) {
340+
*(void **)result = NULL;
341+
return 1;
342+
}
343+
expr_ty res = _PyPegen_set_expr_context(p, name, Store);
344+
if (res == NULL) {
345+
p->error_indicator = 1;
346+
}
347+
*(void **)result = res;
348+
return 1;
349+
}
350+
244351
int
245352
_PyPegen_fill_token(Parser *p)
246353
{

Parser/pegen.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ 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);
163+
int _PyPegen_target_fast_path(Parser *p, void *result);
162164
expr_ty _PyPegen_name_token(Parser *p);
163165
expr_ty _PyPegen_number_token(Parser *p);
164166
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)