Skip to content

Commit e5a41c6

Browse files
committed
make_simplified_union: add caching and reduce allocations
make_simplified_union is used in a lot of places and therefore accounts for a significant share to typechecking time. Based on sample metrics gathered from a large real-world codebase we can see that: 1. the majority of inputs are already as simple as they're going to get, which means we can avoid allocation extra lists and return the input unchanged 2. most of the cost of `make_simplified_union` comes from `is_proper_subtype` 3. `is_proper_subtype` has some caching going on under the hood but it only applies to `Instance`, and cache hit rate is low in this particular case because, as per 1) above, items are in fact rarely subtypes of each other To address 1, refactor `make_simplified_union` with an optimistic fast path that avoid unnecessary allocations. To address 2 & 3, introduce a cache to record the result of union simplification. These changes are observed to yield significant improvements in a real-world codebase: a roughly 10-20% overall speedup, with make_simplified_union/is_proper_subtype no longer showing up as hotspots in the py-spy profile. For #12526
1 parent d1c0616 commit e5a41c6

1 file changed

Lines changed: 101 additions & 29 deletions

File tree

‎mypy/typeops.py‎

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
since these may assume that MROs are ready.
66
"""
77

8-
from typing import cast, Optional, List, Sequence, Set, Iterable, TypeVar, Dict, Tuple, Any, Union
8+
from typing import (
9+
cast, Optional, List, Sequence, Set, Iterable, TypeVar, Dict, Tuple, Any, Union, Callable
10+
)
911
from typing_extensions import Type as TypingType
1012
import itertools
1113
import sys
@@ -336,6 +338,47 @@ def is_simple_literal(t: ProperType) -> bool:
336338
return False
337339

338340

341+
def _get_flattened_proper_types(items: Sequence[Type]) -> Sequence[ProperType]:
342+
"""Similar to types.get_proper_types, with flattening of UnionType
343+
344+
Optimized to avoid allocating a new list whenever possible"""
345+
i: int = 0
346+
base: int = 0
347+
n: int = len(items)
348+
349+
# optimistic fast path
350+
while i < n:
351+
t = items[i]
352+
pt = get_proper_type(t)
353+
if id(t) != id(pt) or isinstance(pt, UnionType):
354+
# we need to allocate, switch to slow path
355+
break
356+
# simplify away any number of bottom type at the start of the input
357+
if i == base and i+1 < n and isinstance(pt, UninhabitedType):
358+
base += 1
359+
i += 1
360+
361+
# optimistic fast path reached end of input, no need to allocate
362+
if i == n:
363+
return cast(Sequence[ProperType], items[base:] if base > 0 else items)
364+
365+
all_items = list(cast(Sequence[ProperType], items[base:i]))
366+
367+
while i < n:
368+
pt = get_proper_type(items[i])
369+
if isinstance(pt, UnionType):
370+
all_items.extend(_get_flattened_proper_types(pt.items))
371+
else:
372+
all_items.append(pt)
373+
i += 1
374+
return all_items
375+
376+
377+
_simplified_union_cache: List[Dict[Tuple[ProperType, ...], ProperType]] = [
378+
{} for _ in range(2**3)
379+
]
380+
381+
339382
def make_simplified_union(items: Sequence[Type],
340383
line: int = -1, column: int = -1,
341384
*, keep_erased: bool = False,
@@ -362,17 +405,35 @@ def make_simplified_union(items: Sequence[Type],
362405
back into a sum type. Set it to False when called by try_expanding_sum_type_
363406
to_union().
364407
"""
365-
items = get_proper_types(items)
366-
367408
# Step 1: expand all nested unions
368-
while any(isinstance(typ, UnionType) for typ in items):
369-
all_items: List[ProperType] = []
370-
for typ in items:
371-
if isinstance(typ, UnionType):
372-
all_items.extend(get_proper_types(typ.items))
373-
else:
374-
all_items.append(typ)
375-
items = all_items
409+
items = _get_flattened_proper_types(items)
410+
411+
cache_fn: Optional[Callable[[ProperType], None]] = None
412+
413+
# 1 or 2 elements account for the vast majority of inputs and are not worth caching:
414+
# - they're two small for the quadratic worst-case cost of simplification to really
415+
# manifest
416+
# - they majority of those inputs are only triggered once
417+
# - avoiding the extra allocations is a bigger win
418+
if len(items) == 1:
419+
return items[0]
420+
elif len(items) > 2:
421+
# NB: ideally we would use a frozenset, but that would require normalizing the
422+
# order of entries in the simplified union, or updating the test harness to
423+
# treat Unions as equivalent regardless of item ordering (which is particularly
424+
# tricky when it comes to all tests using string matching on reveal_type output)
425+
cache_key = tuple(items)
426+
# NB: we need to maintain separate caches depending on flags that might impact
427+
# the results of simplification
428+
cache = _simplified_union_cache[
429+
int(keep_erased)
430+
| int(contract_literals) << 1
431+
| int(state.strict_optional) << 2
432+
]
433+
ret = cache.get(cache_key, None)
434+
if ret is not None:
435+
return ret
436+
cache_fn = lambda v: cache.__setitem__(cache_key, v) # noqa: E731
376437

377438
# Step 2: remove redundant unions
378439
simplified_set = _remove_redundant_union_items(items, keep_erased)
@@ -381,13 +442,20 @@ def make_simplified_union(items: Sequence[Type],
381442
if contract_literals and sum(isinstance(item, LiteralType) for item in simplified_set) > 1:
382443
simplified_set = try_contracting_literals_in_union(simplified_set)
383444

384-
return UnionType.make_union(simplified_set, line, column)
445+
ret = UnionType.make_union(simplified_set, line, column)
446+
447+
if cache_fn:
448+
cache_fn(ret)
449+
450+
return ret
385451

386452

387-
def _remove_redundant_union_items(items: List[ProperType], keep_erased: bool) -> List[ProperType]:
453+
def _remove_redundant_union_items(items: Sequence[ProperType],
454+
keep_erased: bool) -> Sequence[ProperType]:
388455
from mypy.subtypes import is_proper_subtype
389456

390457
removed: Set[int] = set()
458+
truthed: Set[int] = set()
391459
seen: Set[Tuple[str, ...]] = set()
392460

393461
# NB: having a separate fast path for Union of Literal and slow path for other things
@@ -397,6 +465,7 @@ def _remove_redundant_union_items(items: List[ProperType], keep_erased: bool) ->
397465
for i, item in enumerate(items):
398466
if i in removed:
399467
continue
468+
400469
# Avoid slow nested for loop for Union of Literal of strings/enums (issue #9169)
401470
k = simple_literal_value_key(item)
402471
if k is not None:
@@ -434,20 +503,34 @@ def _remove_redundant_union_items(items: List[ProperType], keep_erased: bool) ->
434503
continue
435504
# actual redundancy checks
436505
if (
437-
is_redundant_literal_instance(item, tj) # XXX?
438-
and is_proper_subtype(tj, item, keep_erased_types=keep_erased)
506+
isinstance(tj, UninhabitedType)
507+
or (
508+
(
509+
not isinstance(item, Instance)
510+
or item.last_known_value is None
511+
or (
512+
isinstance(tj, Instance)
513+
and tj.last_known_value == item.last_known_value
514+
)
515+
)
516+
and is_proper_subtype(tj, item, keep_erased_types=keep_erased)
517+
)
439518
):
440519
# We found a redundant item in the union.
441520
removed.add(j)
442521
cbt = cbt or tj.can_be_true
443522
cbf = cbf or tj.can_be_false
523+
444524
# if deleted subtypes had more general truthiness, use that
445525
if not item.can_be_true and cbt:
446-
items[i] = true_or_false(item)
526+
truthed.add(i)
447527
elif not item.can_be_false and cbf:
448-
items[i] = true_or_false(item)
528+
truthed.add(i)
449529

450-
return [items[i] for i in range(len(items)) if i not in removed]
530+
if not removed and not truthed:
531+
return items
532+
return [true_or_false(items[i]) if i in truthed else items[i]
533+
for i in range(len(items)) if i not in removed]
451534

452535

453536
def _get_type_special_method_bool_ret_type(t: Type) -> Optional[Type]:
@@ -889,17 +972,6 @@ def custom_special_method(typ: Type, name: str, check_all: bool = False) -> bool
889972
return False
890973

891974

892-
def is_redundant_literal_instance(general: ProperType, specific: ProperType) -> bool:
893-
if not isinstance(general, Instance) or general.last_known_value is None:
894-
return True
895-
if isinstance(specific, Instance) and specific.last_known_value == general.last_known_value:
896-
return True
897-
if isinstance(specific, UninhabitedType):
898-
return True
899-
900-
return False
901-
902-
903975
def separate_union_literals(t: UnionType) -> Tuple[Sequence[LiteralType], Sequence[Type]]:
904976
"""Separate literals from other members in a union type."""
905977
literal_items = []

0 commit comments

Comments
 (0)