Skip to content

Commit 605c6d7

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 failing alternatives for assignment targets and subscripts. 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 605c6d7

9 files changed

Lines changed: 244 additions & 5 deletions

File tree

Grammar/python.gram

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

@@ -889,7 +889,7 @@ slices[expr_ty]:
889889
| a=slice !',' { a }
890890
| a[asdl_expr_seq*]=','.(slice | starred_expression)+ [','] { _PyAST_Tuple(a, Load, EXTRA) }
891891

892-
slice[expr_ty]:
892+
slice[expr_ty] (fastpath=_PyPegen_slice_fast_path):
893893
| a=[expression] ':' b=[expression] c=[':' d=[expression] { d }] { _PyAST_Slice(a, b, c, EXTRA) }
894894
| a=named_expression { a }
895895

@@ -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: 16 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: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,135 @@ _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+
290+
// Fast path for subscript elements, attached to the slice rule via the
291+
// (fastpath=function) grammar flag: a bare NAME/NUMBER atom followed by
292+
// ',' or ']' is a complete slice, so it is built directly instead of
293+
// trying the subscript alternative (which fails and backtracks) first.
294+
// Same contract and token-fill discipline as _PyPegen_atom_fast_path().
295+
int
296+
_PyPegen_slice_fast_path(Parser *p, void *result)
297+
{
298+
if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) {
299+
p->error_indicator = 1;
300+
*(void **)result = NULL;
301+
return 1;
302+
}
303+
Token *t = p->tokens[p->mark];
304+
if (t->type != NAME && t->type != NUMBER) {
305+
return 0;
306+
}
307+
if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) {
308+
p->error_indicator = 1;
309+
*(void **)result = NULL;
310+
return 1;
311+
}
312+
switch (p->tokens[p->mark + 1]->type) {
313+
case COMMA:
314+
case RSQB:
315+
break;
316+
default:
317+
return 0;
318+
}
319+
expr_ty res = (t->type == NAME)
320+
? _PyPegen_name_token(p) : _PyPegen_number_token(p);
321+
*(void **)result = res;
322+
return 1;
323+
}
324+
325+
// Fast path for assignment targets, attached to target_with_star_atom via
326+
// the (fastpath=function) grammar flag: a bare NAME followed by a token
327+
// that cannot extend a target parses to that name in Store context,
328+
// skipping the t_primary attribute/subscript attempts. NAME only: literals
329+
// must keep failing through the normal path so "cannot assign to literal"
330+
// errors are unaffected. Same contract and token-fill discipline as
331+
// _PyPegen_atom_fast_path().
332+
int
333+
_PyPegen_target_fast_path(Parser *p, void *result)
334+
{
335+
if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) {
336+
p->error_indicator = 1;
337+
*(void **)result = NULL;
338+
return 1;
339+
}
340+
Token *t = p->tokens[p->mark];
341+
if (t->type != NAME) {
342+
return 0;
343+
}
344+
if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) {
345+
p->error_indicator = 1;
346+
*(void **)result = NULL;
347+
return 1;
348+
}
349+
switch (p->tokens[p->mark + 1]->type) {
350+
case EQUAL:
351+
case COMMA:
352+
case RPAR:
353+
case RSQB:
354+
case COLON:
355+
case NEWLINE:
356+
break;
357+
default:
358+
return 0;
359+
}
360+
expr_ty name = _PyPegen_name_token(p);
361+
if (name == NULL) {
362+
*(void **)result = NULL;
363+
return 1;
364+
}
365+
expr_ty res = _PyPegen_set_expr_context(p, name, Store);
366+
if (res == NULL) {
367+
p->error_indicator = 1;
368+
}
369+
*(void **)result = res;
370+
return 1;
371+
}
372+
244373
int
245374
_PyPegen_fill_token(Parser *p)
246375
{

Parser/pegen.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ 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_slice_fast_path(Parser *p, void *result);
164+
int _PyPegen_target_fast_path(Parser *p, void *result);
162165
expr_ty _PyPegen_name_token(Parser *p);
163166
expr_ty _PyPegen_number_token(Parser *p);
164167
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)