Skip to content

[ty] Lazily initialize UnionBuilder when transforming a union type - #24929

Merged
charliermarsh merged 9 commits into
astral-sh:mainfrom
mtshiba:identity-preservation
May 8, 2026
Merged

charliermarsh merged 9 commits into
astral-sh:mainfrom
mtshiba:identity-preservation

Conversation

@mtshiba

@mtshiba mtshiba commented Apr 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This early return improves performance.

I tried the same thing at intersections, but there wasn't much change. It seems UnionType::map_leave_aliases was a particularly hotspot.

Test Plan

@mtshiba mtshiba added performance Potential performance improvement ty The ty type checker labels Apr 29, 2026
@astral-sh-bot

astral-sh-bot Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 89.36%. The percentage of expected errors that received a diagnostic held steady at 85.49%. The number of fully passing files held steady at 88/134.

@astral-sh-bot

astral-sh-bot Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
flake8 47.85MB 47.81MB -0.08% (39.50kB) ⬇️
trio 117.60MB 117.45MB -0.13% (157.90kB) ⬇️
sphinx 262.13MB 261.76MB -0.14% (381.08kB) ⬇️
prefect 695.29MB 693.95MB -0.19% (1.34MB) ⬇️

Significant changes

Click to expand detailed breakdown

flake8

Name Old New Diff Outcome
Type<'db>::apply_specialization_ 208.34kB 189.02kB -9.28% (19.32kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 27.00kB 19.31kB -28.47% (7.69kB) ⬇️
infer_definition_types 1.87MB 1.87MB -0.22% (4.24kB) ⬇️
infer_expression_types_impl 1.02MB 1.02MB -0.20% (2.13kB) ⬇️
infer_scope_types_impl 978.41kB 976.43kB -0.20% (1.98kB) ⬇️
FunctionType<'db>::signature_ 377.18kB 375.77kB -0.38% (1.42kB) ⬇️
when_constraint_set_assignable_to_owned_impl 79.04kB 77.67kB -1.73% (1.37kB) ⬇️
Type<'db>::try_call_dunder_get_ 366.10kB 365.60kB -0.14% (516.00B) ⬇️
FunctionType<'db>::last_definition_signature_ 61.17kB 60.80kB -0.59% (372.00B) ⬇️
is_redundant_with_impl::interned_arguments 84.73kB 85.08kB +0.41% (352.00B) ⬇️
Type<'db>::expand_eagerly__ 1.92kB 1.59kB -17.11% (336.00B) ⬇️
StaticClassLiteral<'db>::try_mro_ 310.14kB 309.86kB -0.09% (288.00B) ⬇️
is_redundant_with_impl 81.27kB 81.46kB +0.23% (192.00B) ⬇️
IntersectionType 45.58kB 45.74kB +0.36% (168.00B) ⬇️
infer_unpack_types 37.08kB 36.94kB -0.38% (144.00B) ⬇️
... 5 more

trio

Name Old New Diff Outcome
Type<'db>::apply_specialization_ 706.10kB 615.80kB -12.79% (90.30kB) ⬇️
infer_expression_types_impl 6.99MB 6.97MB -0.29% (20.81kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 74.34kB 62.52kB -15.90% (11.82kB) ⬇️
infer_definition_types 7.81MB 7.80MB -0.07% (5.72kB) ⬇️
when_constraint_set_assignable_to_owned_impl 200.46kB 195.64kB -2.40% (4.82kB) ⬇️
infer_scope_types_impl 4.73MB 4.73MB -0.10% (4.80kB) ⬇️
ClassType<'db>::into_callable_ 7.78kB 5.07kB -34.79% (2.71kB) ⬇️
FunctionType<'db>::signature_ 1.11MB 1.11MB -0.20% (2.25kB) ⬇️
code_generator_of_static_class 308.80kB 306.77kB -0.66% (2.04kB) ⬇️
infer_expression_type_impl 1.31MB 1.31MB -0.15% (1.97kB) ⬇️
FunctionType<'db>::last_definition_signature_ 250.73kB 249.23kB -0.60% (1.50kB) ⬇️
Type<'db>::class_member_with_policy_ 2.04MB 2.04MB -0.07% (1.48kB) ⬇️
Type<'db>::try_call_dunder_get_ 1.34MB 1.34MB -0.09% (1.23kB) ⬇️
StaticClassLiteral<'db>::try_mro_ 793.77kB 792.79kB -0.12% (1008.00B) ⬇️
infer_deferred_types 2.33MB 2.33MB -0.04% (948.00B) ⬇️
... 17 more

sphinx

Name Old New Diff Outcome
Type<'db>::apply_specialization_ 1.60MB 1.42MB -11.09% (181.14kB) ⬇️
infer_expression_types_impl 20.56MB 20.48MB -0.40% (84.64kB) ⬇️
is_redundant_with_impl::interned_arguments 1.08MB 1.12MB +4.47% (49.24kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 276.91kB 232.15kB -16.17% (44.77kB) ⬇️
infer_definition_types 23.92MB 23.88MB -0.16% (39.63kB) ⬇️
is_redundant_with_impl 929.23kB 954.22kB +2.69% (25.00kB) ⬇️
infer_scope_types_impl 15.49MB 15.47MB -0.13% (19.92kB) ⬇️
when_constraint_set_assignable_to_owned_impl 940.44kB 922.00kB -1.96% (18.43kB) ⬇️
Type<'db>::class_member_with_policy_ 7.64MB 7.63MB -0.20% (15.66kB) ⬇️
try_call_bin_op_return_type_impl 205.95kB 199.92kB -2.93% (6.04kB) ⬇️
all_narrowing_constraints_for_expression 2.47MB 2.46MB -0.23% (5.81kB) ⬇️
infer_expression_type_impl 2.90MB 2.90MB -0.19% (5.51kB) ⬇️
FunctionType<'db>::signature_ 2.38MB 2.38MB -0.22% (5.25kB) ⬇️
all_negative_narrowing_constraints_for_expression 2.38MB 2.38MB -0.21% (5.19kB) ⬇️
infer_deferred_types 5.27MB 5.27MB -0.09% (4.64kB) ⬇️
... 18 more

prefect

Name Old New Diff Outcome
Type<'db>::apply_specialization_ 3.70MB 3.08MB -16.79% (635.47kB) ⬇️
infer_definition_types 87.49MB 87.25MB -0.27% (244.83kB) ⬇️
infer_expression_types_impl 57.46MB 57.31MB -0.26% (152.14kB) ⬇️
infer_scope_types_impl 56.78MB 56.67MB -0.20% (116.52kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 325.71kB 256.49kB -21.25% (69.22kB) ⬇️
when_constraint_set_assignable_to_owned_impl 1.55MB 1.51MB -2.49% (39.48kB) ⬇️
function_known_decorators 4.56MB 4.53MB -0.55% (25.68kB) ⬇️
Type<'db>::try_call_dunder_get_ 10.84MB 10.83MB -0.17% (18.68kB) ⬇️
infer_deferred_types 11.22MB 11.21MB -0.08% (9.11kB) ⬇️
Type<'db>::class_member_with_policy_ 17.22MB 17.21MB -0.05% (8.06kB) ⬇️
FunctionType<'db>::signature_ 4.15MB 4.15MB -0.17% (7.35kB) ⬇️
FunctionType<'db>::last_definition_raw_signature_ 3.72MB 3.72MB -0.16% (6.05kB) ⬇️
is_redundant_with_impl 1.92MB 1.91MB -0.29% (5.68kB) ⬇️
FunctionType<'db>::last_definition_signature_ 821.00kB 815.59kB -0.66% (5.41kB) ⬇️
infer_expression_type_impl 7.59MB 7.59MB -0.05% (4.01kB) ⬇️
... 18 more

@astral-sh-bot

astral-sh-bot Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Full report with detailed diff (timing results)

Comment thread crates/ty_python_semantic/src/types/set_theoretic.rs Outdated
@codspeed

codspeed Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 16.14%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 7 improved benchmarks
✅ 50 untouched benchmarks
⏩ 60 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ Simulation ty_micro[typevar_mapping_accumulation] 371.2 ms 319.6 ms +16.14%
⚡ WallTime static_frame 21.8 s 20.6 s +5.51%
⚡ WallTime pandas 64.2 s 60.5 s +6.2%
⚡ WallTime altair 5.1 s 4.7 s +8.84%
⚡ WallTime multithreaded 1.2 s 1.1 s +13.09%
⚡ WallTime colour_science 44.1 s 38.5 s +14.59%
⚡ Simulation hydra-zen 1.2 s 1.1 s +10.1%

Comparing mtshiba:identity-preservation (23b60b3) with main (37ae52f)

Open in CodSpeed

Footnotes

  1. 60 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@mtshiba mtshiba changed the title [ty] don't use UnionBuilder if the elements of the union have not been changed by a transformation [ty] Lazily initialize UnionBuilder when transforming a union type Apr 29, 2026
@mtshiba
mtshiba marked this pull request as ready for review April 29, 2026 17:08
@astral-sh-bot
astral-sh-bot Bot requested a review from charliermarsh April 29, 2026 17:08
@carljm
carljm removed their request for review April 29, 2026 17:27

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

I'm a bit surprised by the change in memory usage. I would expect this not to change memory usage at all. Do you know where the difference is coming from?

Comment on lines +135 to +155
let elements = self.elements(db);
let mut builder: Option<UnionBuilder<'db>> = None;
for (i, ty) in elements.iter().enumerate() {
let new_ty = transform_fn(ty);
if let Some(b) = builder.as_mut() {
b.add_in_place(new_ty);
} else if &new_ty != ty {
// First change at index `i`. Initialize the builder and
// replay the unchanged prefix (we know `elements[..i]`
// mapped to themselves, so we pass the originals directly).
let mut b = UnionBuilder::new(db);
for prev in &elements[..i] {
b.add_in_place(*prev);
}
b.add_in_place(new_ty);
builder = Some(b);
}
}
let Some(builder) = builder else {
return Type::Union(self);
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: You can avoid some of the branching here by rewriting this to:

        let elements = self.elements(db);

        let mut iter = elements.iter().enumerate();
        while let Some((i, ty)) = iter.next() {
            let new_ty = transform_fn(ty);

            if &new_ty != ty {
                // First change at index `i`. Initialize the builder and
                // replay the unchanged prefix (we know `elements[..i]`
                // mapped to themselves, so we pass the originals directly).
                let mut b = UnionBuilder::new(db);
                for prev in &elements[..i] {
                    b.add_in_place(*prev);
                }
                b.add_in_place(new_ty);

                for (_, element) in iter {
                    b.add_in_place(transform_fn(element));
                }

                return b.recursively_defined(self.recursively_defined(db)).build();
            }
        }

        return Type::Union(self);

// mapped to themselves, so we pass the originals directly).
let mut b = UnionBuilder::new(db);
for prev in &elements[..i] {
b.add_in_place(*prev);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to have a method here that adds the element without doing any merging.

// First change at index `i`. Initialize the builder and
// replay the unchanged prefix (we know `elements[..i]`
// mapped to themselves, so we pass the originals directly).
let mut b = UnionBuilder::new(db);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to set recursively_defined here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think no... because this builder doesn't use cycle recovery...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it seems to correctly preserve the existing behavior.

But I'm a bit surprised that we only set it at the end, once we fully build the union, even though recursively_defined is read in add_in_place (in which case it is always false, regardless of what self.recursively_defined was)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flag only changes the behavior when performing a union with the type of the previous iteration and the type of the current iteration during cycle recovery.
The reason for re-attaching the flag here is simply to prevent the flag that was attached to the original union from being lost.

@charliermarsh
charliermarsh merged commit c79efff into astral-sh:main May 8, 2026
58 checks passed
@ibraheemdev

Copy link
Copy Markdown
Member

The new push_normalized method seems to be similar to add_unchecked_in_place which was reverted in c99f6b4, @mtshiba is there a reason you reverted that?

@MichaReiser

MichaReiser commented May 8, 2026 •

Copy link
Copy Markdown
Member

I think we should revert this PR because of this codex finding (this PR is not behavior preserving)

[P1] push_normalized can skip literal-widening invariants. In UnionType::{map,map_leave_aliases,try_map}, the unchanged prefix is replayed as raw UnionElement::Type via push_normalized (set_theoretic.rs around the new prefix loops, builder.rs around push_normalized). That preserves many redundancy checks against later changed elements, but it does not rebuild the literal buckets used by UnionBuilder to count and widen large literal unions. Example shape: an existing union with 256 int literals plus one non-literal, where the non-literal maps to another int literal. The old path would rebuild from scratch and widen; the new path can return an oversized literal union because the 256-prefix literals were not counted as IntLiterals. Same class of issue applies to string/bytes/enum literal buckets and possibly promotability merging.

and

[P2] UnionType::map now preserves aliases in cases where it previously unpacked them. The old map always used UnionBuilder::new(db), while only map_leave_aliases opted out of unpacking. The new identity fast path returns Type::Union(self) directly, and the changed path replays the unchanged prefix with push_normalized. If map is ever called on a union that was built with aliases left in place, this changes behavior.

@charliermarsh

Copy link
Copy Markdown
Member

I’ll revert. Those both seem fixable? So I can follow up on them separately!

@MichaReiser

Copy link
Copy Markdown
Member

I'm working on a follow up fix, I think I'm close :)

@charliermarsh

Copy link
Copy Markdown
Member

Thanks king!

carljm added a commit that referenced this pull request May 8, 2026
* main: (23 commits)
  Lazily initialize `UnionBuilder` when transforming a `Union` (#25050)
  [ty] Speed-up find-references by using multithreading for cross-file searches (#25042)
  Revert "[ty] Lazily initialize `UnionBuilder` when transforming a union type" (#25049)
  [ty] Make text-based find-references pre-filter identifier boundary aware (#25036)
  [ty] Fix cross-file find-references for keyword arguments (#25043)
  [ty] Include TypedDict type context when inferring mixed constructors (#25039)
  [ty] Include TypedDict type context when inferring string keys (#25037)
  [ty] Lazily initialize `UnionBuilder` when transforming a union type (#24929)
  [ty] Skip global search for references if identifier is not externally visible (#25033)
  [ty] Support narrowing on `__class__` checks (#24997)
  [ty] Bump ecosystem-analyzer (#25032)
  [ty] Include error context for overload consistency diagnostics (#24950)
  [ty] Check non-generic overload implementations (#24936)
  Filter overloads based on return type for ParamSpec mapping (#24769)
  [ty] Add versioned parameters for dataclass decorator (#25029)
  [ty] Project reachability constraints before narrowing (#24982)
  [ty] Emit folding ranges from the language server for multi-line block headers. (#24978)
  [ty] Allow ParamSpec specialization through unioned generic classes (#24826)
  [ty] Adjust start of block folding range to preserve visible header for character-precise LSP clients. (#24917)
  [ty] Use `as_i64` when comparing `IntLiteralType` instances (#25023)
  ...
@mtshiba

mtshiba commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

I'm sorry I haven't been able to get to this for a while.
It appears that further work is already underway, but I will answer the questions anyway.

I'm a bit surprised by the change in memory usage. I would expect this not to change memory usage at all. Do you know where the difference is coming from?

This isn't the kind of optimization that reduces query calls or increases cache hit rates, so the cause is probably the size of the query metadata.
In the memory report, the biggest decrease is in Type::apply_specialization. This internally calls UnionType::map_leave_aliases. Many subqueries are called during UnionBuilder processing (e.g., Type::is_redundant_with). Since these are now often not called with early returns, I suspect the average size of the dependency graph has decreased.

The new push_normalized method seems to be similar to add_unchecked_in_place which was reverted in c99f6b4, @mtshiba is there a reason you reverted that?

It simply turned out to be not very effective.

I think we should revert this PR because of this codex finding (this PR is not behavior preserving)

The point that using this trick in (try_)map isn't behavior-preserving is certainly valid.
However, given that there are no changes to the ecosystem, this may rather indicate that there aren't that many places where we need to differentiate between map and map_leave_aliases. Ideally, alias expansion should be delayed as much as possible when building a union. Indeed, using map_leave_aliases in all cases seems to cause some mdtests to fail, but the criteria for when to use each is unclear to me. I think that performing operations on an alias-expanded union type and performing operations on an unexpanded union type should be consistent.

Anyway, it shows that the main performance improvement from this change actually comes from map_leave_aliases, which should be sufficient on its own.

thejchap pushed a commit to thejchap/ruff that referenced this pull request May 23, 2026
…stral-sh#24929)

## Summary

This early return improves performance.

I tried the same thing at intersections, but there wasn't much change.
It seems `UnionType::map_leave_aliases` was a particularly hotspot.

## Test Plan

---------

Co-authored-by: Charlie Marsh <[email protected]>
thejchap pushed a commit to thejchap/ruff that referenced this pull request May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Potential performance improvement ty The ty type checker

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants