Skip to content

Skip rescanning operands whose scan already failed - #2090

Open
LucaCappelletti94 wants to merge 1 commit into
dtolnay:masterfrom
LucaCappelletti94:upstream/fixup-linear-scan
Open

LucaCappelletti94 wants to merge 1 commit into
dtolnay:masterfrom
LucaCappelletti94:upstream/fixup-linear-scan

Conversation

@LucaCappelletti94

@LucaCappelletti94 LucaCappelletti94 commented Oct 29, 2026 •

Copy link
Copy Markdown

While fuzzing a crate that uses syn, I landed on this crash case (same as #1864). I usually just steer the fuzzer away from these and that is it, since often such cases are just adversarial and not code that is in any way reasonable, plus often handling these cases reduces the performance of actual proper code parsing, this time on a whim I tried to have Opus 5.5 look into it and it happened to find a simple solution that gives better performance overall. I reviewed well the solution and it looks reasonable to me.

Put briefly, printing an expression whose rightmost operands nest ranges, prefix operators or binary operators takes time exponential in the nesting depth, with each nested level roughly doubling.

In scan_right, the binary, prefix, range and let arms scan their operand, and when that scan does not return Consume they call rightmost_subexpression_precedence. That call scans the same operand a second time, so every level of nesting doubles the work. The second scan only matters if it returns Consume, and it never does.

In the binary, range and let arms, the second scan uses the same precedence as the first. Only the offsets differ, and they cannot change the result. fail_offset is always 0 or 1 but is only ever compared with 2, and bailout_offset can only cause an early Consume. So the second scan returns what the first one did. The prefix arm scans again with Prefix in place of the incoming precedence. The few operands where that changes the result still do not return Consume, or are already excluded by scan_left, or by next_operator_can_continue_expr, which is always false when next_operator is Jump.

This skips the second scan at those four call sites, so each scan visits an operand once, which is what allows for the generally improved performance reported below.

use quote::ToTokens;

let expr: syn::Expr = syn::parse_str(
    "0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0
    | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 | ..0 = 0",
).unwrap();
let tokens = expr.to_token_stream();
| ..0 repetitions before after
1 0.48 µs 0.49 µs
2 0.70 µs 0.62 µs
3 1.2 µs 0.94 µs
4 2.1 µs 1.2 µs
6 6.7 µs 2.1 µs
8 24 µs 3.0 µs
12 367 µs 5.1 µs
16 5.9 ms 7.9 µs
20 93 ms 11.5 µs
24 1.51 s 15.3 µs
25 (the input above) 3.54 s 16.9 µs

Fixes #1864.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Found slow units on syn::parse_str using cargofuzz

1 participant