From bf15c8cfcc24e4527c87c006b1a7b6225bd80af3 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 14 Aug 2026 04:04:37 -0700 Subject: [PATCH] [ty] Avoid exponential narrowing of gradual string-literal unions (#27742) ## Summary Fix the exponential memory regression when equality narrowing encounters a union of `Any & Literal["..."]` alternatives. `evaluate_target_union` intersected each surviving alternative with the complement of every rejected alternative, even when the survivor and rejected union were already disjoint. For gradual string literals, expanding those redundant complements created exponentially many equivalent intersections. Skip the exclusions when disjointness is proven; preserve them for alternatives that can overlap, including `Any` and `TypeVar` cases. On the original 20-member reproducer, peak memory drops from **304.4 MiB to 38.8 MiB**; the reduced intersection-only reproducer drops from **301.4 MiB to 38.5 MiB**. The original 67-member case, previously exponential/OOM, now completes in **0.061 s using 42.0 MiB**. Closes astral-sh/ty#4256. ## Test plan - Added mdtests covering both branches of `==` and `!=` for gradual string-literal unions, plus a 20-member regression that exercises the original exponential expansion. - Added the dedicated `ty_micro[gradual_literal_union_equality]` CodSpeed microbenchmark for the 20-member case. - Ran all 481 ty semantic mdtests and the repository hooks. --- crates/ruff_benchmark/benches/ty.rs | 34 ++++++++++++ .../mdtest/narrow/conditionals/eq.md | 53 +++++++++++++++++++ .../ty_python_semantic/src/types/equality.rs | 8 ++- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index faae0f88b5..f36a145da2 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1254,6 +1254,39 @@ fn benchmark_literal_equality_fallthrough_guarded_any(criterion: &mut Criterion) ); } +/// Regression benchmark for . +/// +/// Excluding rejected gradual string literals must not expand the complement of each intersection +/// into exponentially many equivalent alternatives. +fn benchmark_gradual_literal_union_equality(criterion: &mut Criterion) { + setup_rayon(); + + let mut code = String::from( + "from typing import Any, Literal\nfrom ty_extensions import Intersection\n\ndef check(value: (\n", + ); + for index in 0..20 { + writeln!( + &mut code, + " {}Intersection[Any, Literal[\"{index}\"]]", + if index == 0 { "" } else { "| " }, + ) + .ok(); + } + code.push_str(")) -> None:\n assert value == \"0\"\n repr(value)\n"); + + criterion.bench_function("ty_micro[gradual_literal_union_equality]", |b| { + b.iter_batched_ref( + || setup_micro_case(&code), + |case| { + let Case { db } = case; + let result = db.check(); + assert_eq!(result.len(), 0); + }, + BatchSize::SmallInput, + ); + }); +} + /// Regression benchmark for . /// /// Reachability analysis for a large literal OR pattern on `Any` used to rebuild the remaining @@ -1582,6 +1615,7 @@ criterion_group!( benchmark_literal_match_fallthrough, benchmark_literal_match_fallthrough_guarded_any, benchmark_literal_equality_fallthrough_guarded_any, + benchmark_gradual_literal_union_equality, benchmark_literal_or_pattern_reachability, benchmark_typeis_narrowing, benchmark_repeated_statement_calls, diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md index 95cd22a1f8..0f15bf57d1 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md @@ -2184,6 +2184,59 @@ def gradual_enum_union_inequality(value: Color | Any, other: Color): reveal_type(value) # revealed: Color | Any ``` +## Unions of gradual string literals + +Comparing a union of string literals intersected with `Any` keeps the matching alternative for +equality and removes it for inequality: + +```py +from typing import Any, Literal +from ty_extensions import Intersection + +def equality(value: Intersection[Any, Literal["a"]] | Intersection[Any, Literal["b"]]): + if value == "a": + reveal_type(value) # revealed: Any & Literal["a"] + else: + reveal_type(value) # revealed: Any & Literal["b"] + + if value != "a": + reveal_type(value) # revealed: Any & Literal["b"] + else: + reveal_type(value) # revealed: Any & Literal["a"] +``` + +Larger unions must narrow without expanding the complement of every rejected alternative, which +would make memory use grow exponentially: + +```py +def larger_union( + value: ( + Intersection[Any, Literal["a"]] + | Intersection[Any, Literal["b"]] + | Intersection[Any, Literal["c"]] + | Intersection[Any, Literal["d"]] + | Intersection[Any, Literal["e"]] + | Intersection[Any, Literal["f"]] + | Intersection[Any, Literal["g"]] + | Intersection[Any, Literal["h"]] + | Intersection[Any, Literal["i"]] + | Intersection[Any, Literal["j"]] + | Intersection[Any, Literal["k"]] + | Intersection[Any, Literal["l"]] + | Intersection[Any, Literal["m"]] + | Intersection[Any, Literal["n"]] + | Intersection[Any, Literal["o"]] + | Intersection[Any, Literal["p"]] + | Intersection[Any, Literal["q"]] + | Intersection[Any, Literal["r"]] + | Intersection[Any, Literal["s"]] + | Intersection[Any, Literal["t"]] + ), +): + if value == "a": + reveal_type(value) # revealed: Any & Literal["a"] +``` + ## Booleans and integers ```py diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index 5400a61036..92a7aeab69 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -1166,7 +1166,13 @@ fn evaluate_target_union<'db>( let Some(mut narrowed) = narrowed else { continue; }; - if let Some(removed) = removed { + // A surviving alternative that is disjoint from every rejected alternative already + // satisfies their exclusions. Constructing those redundant exclusions can exponentially + // expand intersections such as `Any & Literal["a"]` when the rejected alternatives are + // similarly shaped intersections with other string literals. + if let Some(removed) = removed + && !narrowed.is_disjoint_from(db, env, removed) + { narrowed = IntersectionBuilder::new(db, env) .add_positive(narrowed) .add_negative(removed)