Skip to content

Commit b77cb89

Browse files
gh-153568: Skip the expression precedence chain for single-token atoms (#153575)
A bare name or number followed by a token that cannot extend an expression now produces its AST node directly. This avoids the expression precedence chain for common arguments, assignments, and subscripts. Rules declare the hook with a fastpath flag. The Rule constructor exposes memo and fastpath attributes, and the C generator emits the hook call. Co-authored-by: Lysandros Nikolaou <lisandrosnik@gmail.com>
1 parent 40c8edd commit b77cb89

15 files changed

Lines changed: 202 additions & 10 deletions

File tree

‎Grammar/python.gram‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,7 @@ named_expression[expr_ty]:
747747
| invalid_named_expression
748748
| expression !':='
749749

750-
disjunction[expr_ty] (memo):
750+
disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
751751
| a=conjunction b=('or' c=conjunction { c })+ { _PyAST_BoolOp(
752752
Or,
753753
CHECK(asdl_expr_seq*, _PyPegen_seq_insert_in_front(p, a, b)),
@@ -761,7 +761,7 @@ conjunction[expr_ty] (memo):
761761
EXTRA) }
762762
| inversion
763763

764-
inversion[expr_ty] (memo):
764+
inversion[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
765765
| 'not' a=inversion { _PyAST_UnaryOp(Not, a, EXTRA) }
766766
| comparison
767767

‎InternalDocs/parser.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,51 @@ actions. As with memoization, actions must not invalidate cached results. In
616616
particular, suffix actions must not move the parser before their starting mark,
617617
rewrite buffered input, or replace memo entries for earlier positions.
618618

619+
Fast-path hooks
620+
---------------
621+
622+
Some rules are entered so often that even the bookkeeping of trying their
623+
alternatives is expensive. Consider parsing the argument ``a`` in a call
624+
like ``f(a, b)``: the argument is a full expression, so the parser descends
625+
the whole operator precedence chain
626+
627+
```
628+
expression -> disjunction -> conjunction -> inversion -> comparison
629+
-> bitwise_or -> bitwise_xor -> bitwise_and -> shift_expr -> sum
630+
-> term -> factor -> power -> await_primary -> primary -> atom
631+
```
632+
633+
before it can produce the ``Name`` node for ``a``: around fourteen rule
634+
invocations, each paying its own C-stack check and memoization lookups, to
635+
consume a single token. But since the following token is ``,``, which
636+
cannot continue any binary or postfix expression, that outcome is already
637+
known after peeking at two tokens.
638+
639+
For cases like this a rule can declare a hand-written C hook with the
640+
``fastpath`` flag, next to where ``memo`` goes:
641+
642+
```
643+
disjunction[expr_ty] (memo, fastpath=_PyPegen_atom_fast_path):
644+
```
645+
646+
The generator calls the hook on rule entry, before any alternative is tried:
647+
648+
```c
649+
if (_PyPegen_atom_fast_path(p, &_res)) {
650+
p->level--;
651+
return _res;
652+
}
653+
```
654+
655+
The hook receives the parser and a pointer to the rule's result variable and
656+
returns 1 if it handled the parse (storing its result, which can be ``NULL``
657+
to signal failure with ``p->error_indicator`` set) or 0 to fall through to
658+
the rule's normal alternatives. The generator knows nothing about what the
659+
hook does: all parsing logic lives in the hook itself, next to the other
660+
token helpers in [`Parser/pegen.c`](../Parser/pegen.c). A hook must behave
661+
exactly like the rule it accelerates, including which tokens it fills,
662+
because error reporting depends on the number of tokens read (``p->fill``).
663+
619664
Automatic variables
620665
-------------------
621666

‎Lib/test/test_peg_generator/test_c_generator.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_parser_plan_does_not_depend_on_compilation_state(self):
6363
@modulename 'sample'
6464
@bytecode '1'
6565
start[mod_ty]: expr_without_invalid 'pass' "zsoft" "asoft" ('bb' | 'aa')* ENDMARKER
66-
expr_without_invalid[expr_ty] (memo): name=expr [NUMBER] { name }
66+
expr_without_invalid[expr_ty] (memo, fastpath=custom_fastpath): name=expr [NUMBER] { name }
6767
expr[expr_ty]: expr '+' NAME | NAME
6868
""")
6969
generator.debug = True
@@ -86,6 +86,8 @@ def test_parser_plan_does_not_depend_on_compilation_state(self):
8686
rule.name = "changed"
8787
rule.type = "changed_type"
8888
rule.flags = frozenset()
89+
rule.memo = False
90+
rule.fastpath = None
8991
rule.rhs.alts[0].action = "changed_action"
9092
rule.rhs.alts.clear()
9193
generator.rules.clear()

‎Lib/test/test_peg_generator/test_c_parser.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,47 @@ def test_rule_cleanup(self) -> None:
464464
"""
465465
self.run_test(grammar_source, test_source)
466466

467+
def test_rule_fastpath(self) -> None:
468+
grammar_source = """
469+
@subheader '''
470+
#define CHECK_INVALID(expected) \\
471+
(assert(p->call_invalid_rules == (expected)), _PyPegen_dummy_name(p))
472+
473+
static int
474+
number_fastpath(Parser *p, void *result)
475+
{
476+
assert(p->call_invalid_rules == 0);
477+
expr_ty number = _PyPegen_number_token(p);
478+
if (number != NULL) {
479+
*(void **)result = number;
480+
return 1;
481+
}
482+
if (_PyPegen_expect_token(p, LPAR)) {
483+
p->error_indicator = 1;
484+
PyErr_SetString(PyExc_ValueError, "fastpath error");
485+
}
486+
if (p->error_indicator) {
487+
*(void **)result = NULL;
488+
return 1;
489+
}
490+
return 0;
491+
}
492+
'''
493+
start: enable value_without_invalid after NEWLINE ENDMARKER
494+
enable: 'enable' { (p->call_invalid_rules = 1, _PyPegen_dummy_name(p)) }
495+
value_without_invalid (memo, fastpath=number_fastpath): NAME { CHECK_INVALID(0) }
496+
after: NAME { CHECK_INVALID(1) }
497+
"""
498+
test_source = """
499+
self.check_input_strings_for_grammar(
500+
valid_cases=['enable 42 tail', 'enable name tail'],
501+
invalid_cases=['enable + tail', 'enable 42'],
502+
)
503+
with self.assertRaisesRegex(ValueError, 'fastpath error'):
504+
parse.parse_string('enable ( tail', mode=0)
505+
"""
506+
self.run_test(grammar_source, test_source)
507+
467508
def test_left_recursive_rule_cleanup(self) -> None:
468509
grammar_source = """
469510
@subheader '''

‎Lib/test/test_peg_generator/test_pegen.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,32 +1181,53 @@ def test_rule_flags(self) -> None:
11811181

11821182
# Test memo-only rule
11831183
simple_rule = rules['simple_rule']
1184-
self.assertTrue('memo' in simple_rule.flags,
1184+
self.assertTrue(simple_rule.memo,
11851185
"simple_rule should have memo")
11861186
self.assertEqual(simple_rule.flags, frozenset(['memo']),
11871187
f"simple_rule flags should be {'memo'}, got {simple_rule.flags}")
11881188

11891189
# Test multi-flag rule
11901190
multi_flag_rule = rules['multi_flag_rule']
1191-
self.assertTrue('memo' in simple_rule.flags,
1191+
self.assertTrue(multi_flag_rule.memo,
11921192
"multi_flag_rule should have memo")
11931193
self.assertEqual(multi_flag_rule.flags, frozenset({'memo', 'custom', 'test'}),
11941194
f"multi_flag_rule flags should contain memo, custom, test, got {multi_flag_rule.flags}")
11951195

11961196
# Test single custom flag rule
11971197
single_custom_rule = rules['single_custom_flag']
1198-
self.assertFalse('memo' not in simple_rule.flags,
1198+
self.assertFalse(single_custom_rule.memo,
11991199
"single_custom_flag should not have memo")
12001200
self.assertEqual(single_custom_rule.flags, frozenset(['custom']),
12011201
f"single_custom_flag flags should be {'custom'}, got {single_custom_rule.flags}")
12021202

12031203
# Test no flags rule
12041204
no_flags_rule = rules['no_flags_rule']
1205-
self.assertFalse('memo' not in simple_rule.flags,
1205+
self.assertFalse(no_flags_rule.memo,
12061206
"no_flags_rule should not have memo")
12071207
self.assertEqual(no_flags_rule.flags, frozenset(),
12081208
f"no_flags_rule flags should be the empty set, got {no_flags_rule.flags}")
12091209

1210+
for rule in rules.values():
1211+
self.assertIsNone(rule.fastpath)
1212+
1213+
def test_rule_fastpath_flags(self) -> None:
1214+
for rule_type in ("", "[expr_ty]"):
1215+
for memo in (False, True):
1216+
with self.subTest(rule_type=rule_type, memo=memo):
1217+
flags = "fastpath=custom_fastpath"
1218+
if memo:
1219+
flags = "memo, " + flags
1220+
grammar_source = f"start{rule_type} ({flags}): NAME\n"
1221+
rule = parse_string(grammar_source, GrammarParser).rules["start"]
1222+
self.assertEqual(rule.fastpath, "custom_fastpath")
1223+
self.assertIs(rule.memo, memo)
1224+
1225+
def test_rule_fastpath_requires_function(self) -> None:
1226+
with self.assertRaisesRegex(
1227+
ValueError, "rule 'start': fastpath flag needs a function"
1228+
):
1229+
parse_string("start (fastpath): NAME\n", GrammarParser)
1230+
12101231

12111232
class TestGrammarIncludes(unittest.TestCase):
12121233
def setUp(self) -> None:
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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,50 @@ _resize_tokens_array(Parser *p) {
328328
return 0;
329329
}
330330

331+
// Fast path attached to the expression precedence chain's entry rules.
332+
// A bare NAME/NUMBER atom followed by a token that cannot start or
333+
// continue a binary/postfix expression is a complete expression, so it is
334+
// built directly instead of descending the whole chain.
335+
//
336+
// Returns 1 if it produced a result (stored in *result), 0 to fall through to
337+
// the rule's alternatives.
338+
int
339+
_PyPegen_atom_fast_path(Parser *p, void *result)
340+
{
341+
if (p->mark == p->fill && _PyPegen_fill_token(p) < 0) {
342+
p->error_indicator = 1;
343+
*(void **)result = NULL;
344+
return 1;
345+
}
346+
Token *t = p->tokens[p->mark];
347+
if (t->type != NAME && t->type != NUMBER) {
348+
return 0;
349+
}
350+
if (p->mark + 1 == p->fill && _PyPegen_fill_token(p) < 0) {
351+
p->error_indicator = 1;
352+
*(void **)result = NULL;
353+
return 1;
354+
}
355+
switch (p->tokens[p->mark + 1]->type) {
356+
case COMMA:
357+
case RPAR:
358+
case RSQB:
359+
case RBRACE:
360+
case COLON:
361+
case NEWLINE:
362+
case SEMI:
363+
case EQUAL:
364+
case ENDMARKER:
365+
break;
366+
default:
367+
return 0;
368+
}
369+
expr_ty res = (t->type == NAME)
370+
? _PyPegen_name_token(p) : _PyPegen_number_token(p);
371+
*(void **)result = res;
372+
return 1;
373+
}
374+
331375
int
332376
_PyPegen_fill_token(Parser *p)
333377
{

‎Parser/pegen.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ Token *_PyPegen_get_last_nonnwhitespace_token(Parser *);
196196
int _PyPegen_fill_token(Parser *p);
197197
// Return -1 on failure, leaving the buffer and capacity unchanged.
198198
int _PyPegen_grow_loop_buffer(void ***buffer, Py_ssize_t *capacity);
199+
int _PyPegen_atom_fast_path(Parser *p, void *result);
199200
expr_ty _PyPegen_name_token(Parser *p);
200201
expr_ty _PyPegen_number_token(Parser *p);
201202
void *_PyPegen_string_token(Parser *p);

‎Tools/peg_generator/pegen/c_generator_calls.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ def candidate(alt: Alt) -> Rule | None:
381381
rule = original_rules.get(alt.items[0].item.value)
382382
if rule is None or rule.name not in consuming:
383383
return None
384-
if ("memo" in rule.flags and not rule.left_recursive) or (
384+
if (rule.memo and not rule.left_recursive) or (
385385
rule.left_recursive and rule.leader
386386
):
387387
return rule
@@ -426,7 +426,8 @@ def prepare_rule(
426426
),
427427
left_recursive=rule.left_recursive,
428428
leader=rule.leader,
429-
memoize="memo" in rule.flags and not rule.left_recursive,
429+
memoize=rule.memo and not rule.left_recursive,
430+
fastpath=rule.fastpath,
430431
disable_invalid_rules=rule.name.endswith("without_invalid"),
431432
prefixes=self._prefixes.get(rule.name, ()),
432433
source=rule.source,

0 commit comments

Comments
 (0)