Skip to content

[ty] Completely remove the NoReturn shortcut optimization#23378

Open
sharkdp wants to merge 1 commit intomainfrom
david/remove-callable-optimization
Open

[ty] Completely remove the NoReturn shortcut optimization#23378
sharkdp wants to merge 1 commit intomainfrom
david/remove-callable-optimization

Conversation

@sharkdp
Copy link
Contributor

@sharkdp sharkdp commented Feb 17, 2026

Summary

Function calls are relevant for control flow analysis because they may return Never/NoReturn, and can therefore be terminal. In order to support this, we record ReturnsNever(…) constraints during semantic index building for statement-level calls (in almost all situations). These constraints keep track of the call expression such that they can be evaluated during reachability analysis and type narrowing.

For example, if we see a call f(a, b, c), we keep track of this full call expression. The return type could depend on the arguments (overloads, generics), so to determine if a call is terminal, we need to evaluate the full call expression.

For performance reasons, this analysis contained a short-cut where we looked at the return type annotation of the invoked callable (f). Under certain conditions, we could immediately see that a call would definitely be terminal. This previously helped with performance, but is now apparently detrimental.

The optimization recently caused problems for generic function calls, so we had to exclude those. It now turns out there was another bug, as I figured out by looking at the ecosystem results on this PR. When the callable expression could not be upcasted to a callable type, we assumed that the call would never be terminal. However, if the callable itself is Never, that should also be considered a terminal call. This can happen in unreachable code. Consider this rather weird case, that I extracted from a hydpy ecosystem hit:

from typing import Never, Literal


def fail() -> Never:
    raise


def _(x: Literal["a", "b"]):
    if x == "a":
        if 1 + 1 == 2:
            return
        fail()
    if x == "b":
        return

    reveal_type(x)

On main, the revealed type of x is Literal["a"], which is wrong. Since the fail() call itself happens in unreachable code, we infer Never for fail itself, and therefore considered the if x == "a" branch to not be terminal. On this branch, that bug is fixed and we correctly reveal Never for the type of x.

So the idea here is to get rid of this optimization all together and to simply evaluate the full call expression in all cases, which also makes this much easier to reason about.

(I find the AlwaysFalse/AlwaysTrue-answer of this evaluation very rather confusing, because it's sort of negated twice; I plan to change this in a follow-up)

Performance

We previously merged #19867 to address a performance problem in this part of the codebase. It seems to me that the original problem was related to the short-cut path, but I'm not sure if this could bring back the lock congestion problem.

In any case, this PR seems to be a performance win across the board on our usual benchmarks. And there is also a small decrease in memory usage.

image

Ecosystem impact

The disappearing diagnostics all seem to be related to cases like above, where the terminal call happened in unreachable code.

Test Plan

Existing tests.

@sharkdp sharkdp added the ty Multi-file analysis & type inference label Feb 17, 2026
@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 17, 2026

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 85.05%. The percentage of expected errors that received a diagnostic held steady at 78.05%. The number of fully passing files held steady at 63/132.

@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 17, 2026

Memory usage report

Summary

Project Old New Diff Outcome
flake8 47.90MB 47.96MB +0.12% (60.38kB)
trio 117.80MB 116.24MB -1.32% (1.56MB) ⬇️
sphinx 265.18MB 263.37MB -0.68% (1.81MB) ⬇️
prefect 701.45MB 694.86MB -0.94% (6.59MB) ⬇️

Significant changes

Click to expand detailed breakdown

flake8

Name Old New Diff Outcome
infer_definition_types 1.86MB 1.95MB +4.86% (92.66kB)
infer_expression_type_impl 157.22kB 212.80kB +35.35% (55.58kB)
semantic_index 13.83MB 13.79MB -0.34% (48.51kB)
infer_expression_types_impl 1.07MB 1.03MB -3.15% (34.42kB)
Expression 365.70kB 334.62kB -8.50% (31.08kB)
loop_header_reachability 13.66kB 42.72kB +212.75% (29.06kB)
CallableType 166.08kB 142.38kB -14.27% (23.70kB)
all_narrowing_constraints_for_expression 82.51kB 102.34kB +24.03% (19.82kB)
BoundMethodType<'db>::into_callable_type_ 26.96kB 13.43kB -50.20% (13.54kB)
infer_unpack_types 37.89kB 48.60kB +28.27% (10.71kB)
all_negative_narrowing_constraints_for_expression 40.30kB 45.99kB +14.12% (5.69kB)
infer_scope_types_impl 1004.28kB 1002.24kB -0.20% (2.04kB)
IntersectionType<'db>::from_two_elements_::interned_arguments 20.62kB 20.71kB +0.42% (88.00B)
IntersectionType<'db>::from_two_elements_ 19.44kB 19.50kB +0.28% (56.00B)
Type<'db>::member_lookup_with_policy_ 409.16kB 409.14kB -0.00% (12.00B)
... 1 more

trio

Name Old New Diff Outcome
infer_expression_types_impl 7.06MB 6.08MB -13.82% (998.98kB) ⬇️
Expression 1.41MB 1.09MB -22.28% (321.33kB) ⬇️
semantic_index 30.33MB 30.04MB -0.96% (298.10kB) ⬇️
infer_expression_type_impl 1.43MB 1.52MB +6.56% (95.80kB) ⬇️
CallableType 572.79kB 490.28kB -14.40% (82.50kB) ⬇️
all_narrowing_constraints_for_expression 600.50kB 638.85kB +6.39% (38.35kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 75.29kB 43.89kB -41.71% (31.41kB) ⬇️
all_negative_narrowing_constraints_for_expression 184.75kB 212.81kB +15.19% (28.06kB) ⬇️
infer_scope_types_impl 4.79MB 4.77MB -0.48% (23.50kB) ⬇️
loop_header_reachability 133.50kB 138.27kB +3.58% (4.78kB) ⬇️
Type<'db>::member_lookup_with_policy_ 1.67MB 1.67MB +0.18% (3.01kB) ⬇️
infer_deferred_types 2.37MB 2.36MB -0.11% (2.73kB) ⬇️
StaticClassLiteral<'db>::implicit_attribute_inner_ 731.63kB 733.82kB +0.30% (2.19kB) ⬇️
is_redundant_with_impl::interned_arguments 538.83kB 536.85kB -0.37% (1.98kB) ⬇️
Type<'db>::try_call_dunder_get_ 1.37MB 1.37MB -0.14% (1.96kB) ⬇️
... 35 more

sphinx

Name Old New Diff Outcome
infer_expression_types_impl 21.50MB 19.81MB -7.86% (1.69MB) ⬇️
semantic_index 62.50MB 61.79MB -1.13% (720.29kB) ⬇️
Expression 3.17MB 2.50MB -21.11% (685.48kB) ⬇️
infer_expression_type_impl 3.20MB 3.81MB +18.88% (619.04kB) ⬇️
infer_definition_types 24.00MB 24.53MB +2.21% (544.38kB) ⬇️
CallableType 1.08MB 928.18kB -15.79% (174.02kB) ⬇️
all_narrowing_constraints_for_expression 2.33MB 2.47MB +6.07% (145.04kB) ⬇️
loop_header_reachability 378.57kB 516.76kB +36.50% (138.19kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 278.58kB 176.06kB -36.80% (102.52kB) ⬇️
all_negative_narrowing_constraints_for_expression 1.01MB 1.08MB +7.33% (75.66kB) ⬇️
infer_scope_types_impl 15.59MB 15.57MB -0.12% (19.89kB) ⬇️
UnionType<'db>::from_two_elements_ 1.35MB 1.36MB +0.70% (9.64kB) ⬇️
infer_unpack_types 446.68kB 454.56kB +1.76% (7.88kB) ⬇️
is_redundant_with_impl::interned_arguments 2.06MB 2.06MB +0.32% (6.70kB) ⬇️
UnionType 1.23MB 1.24MB +0.43% (5.45kB) ⬇️
... 26 more

prefect

Name Old New Diff Outcome
infer_expression_types_impl 60.37MB 55.27MB -8.44% (5.09MB) ⬇️
semantic_index 172.48MB 170.60MB -1.09% (1.88MB) ⬇️
Expression 8.45MB 6.70MB -20.75% (1.75MB) ⬇️
infer_definition_types 88.39MB 89.57MB +1.33% (1.17MB) ⬇️
all_narrowing_constraints_for_expression 6.95MB 7.26MB +4.54% (323.02kB) ⬇️
infer_expression_type_impl 14.27MB 14.56MB +2.01% (293.92kB) ⬇️
CallableType 1.89MB 1.61MB -14.99% (290.55kB) ⬇️
StaticClassLiteral<'db>::implicit_attribute_inner_ 9.70MB 9.91MB +2.21% (219.42kB) ⬇️
all_negative_narrowing_constraints_for_expression 2.58MB 2.77MB +7.48% (197.41kB) ⬇️
Type<'db>::member_lookup_with_policy_ 15.34MB 15.52MB +1.20% (188.98kB) ⬇️
Type<'db>::class_member_with_policy_ 17.21MB 17.33MB +0.68% (120.35kB) ⬇️
BoundMethodType<'db>::into_callable_type_ 304.04kB 185.29kB -39.06% (118.76kB) ⬇️
loop_header_reachability 427.54kB 523.09kB +22.35% (95.55kB) ⬇️
infer_scope_types_impl 52.63MB 52.57MB -0.11% (60.82kB) ⬇️
Type<'db>::try_call_dunder_get_ 10.43MB 10.42MB -0.12% (12.52kB) ⬇️
... 27 more

@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 17, 2026

mypy_primer results

Changes were detected when running on open source projects
pip (https://github.com/pypa/pip)
- src/pip/_vendor/packaging/version.py:48:24: error[call-non-callable] Object of type `object` is not callable
- Found 700 diagnostics
+ Found 699 diagnostics

pydantic (https://github.com/pydantic/pydantic)
- pydantic/v1/utils.py:613:16: error[invalid-return-type] Return type does not match returned value: expected `Mapping[int | str, Any]`, found `(AbstractSet[int | str] & Top[Mapping[Unknown, object]]) | (Mapping[int | str, Any] & AbstractSet[object]) | (Mapping[int | str, Any] & ~AbstractSet[object]) | dict[int | str, ellipsis]`
+ pydantic/v1/utils.py:613:16: error[invalid-return-type] Return type does not match returned value: expected `Mapping[int | str, Any]`, found `(AbstractSet[int | str] & Top[Mapping[Unknown, object]]) | Mapping[int | str, Any] | dict[int | str, ellipsis]`

trio (https://github.com/python-trio/trio)
+ src/trio/_core/_tests/test_run.py:2440:49: warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- Found 469 diagnostics
+ Found 470 diagnostics

setuptools (https://github.com/pypa/setuptools)
- setuptools/_vendor/packaging/version.py:48:24: error[call-non-callable] Object of type `object` is not callable
- Found 1177 diagnostics
+ Found 1176 diagnostics

egglog-python (https://github.com/egraphs-good/egglog-python)
- python/egglog/egraph.py:1962:24: error[invalid-argument-type] Argument to function `expr_action` is incorrect: Expected `BaseExpr`, found `(BaseExpr & ~Action) | (Fact & ~Action)`
- Found 1476 diagnostics
+ Found 1475 diagnostics

hydpy (https://github.com/hydpy-dev/hydpy)
- hydpy/core/devicetools.py:2425:9: error[type-assertion-failure] Type `Literal["inlets", "outlets", "observers", "receivers", "senders", "inputs", "outputs"]` is not equivalent to `Never`
- Found 1067 diagnostics
+ Found 1066 diagnostics

@codspeed-hq
Copy link

codspeed-hq bot commented Feb 17, 2026

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 30 skipped benchmarks1


Comparing david/remove-callable-optimization (4eb2a72) with main (8d5d41c)

Open in CodSpeed

Footnotes

  1. 30 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.

@sharkdp sharkdp force-pushed the david/remove-callable-optimization branch from 61263d4 to 88cf7b2 Compare February 17, 2026 18:51
@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 17, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
call-non-callable 0 2 0
invalid-argument-type 0 1 0
invalid-return-type 0 0 1
type-assertion-failure 0 1 0
unused-type-ignore-comment 1 0 0
Total 1 4 1

Full report with detailed diff (timing results)

@sharkdp sharkdp force-pushed the david/remove-callable-optimization branch from 26011a1 to f87e255 Compare February 18, 2026 10:21
@sharkdp sharkdp changed the title [ty] Experiment: remove NoReturn shortcut optimization [ty] Remove NoReturn shortcut optimization Feb 19, 2026
@sharkdp sharkdp force-pushed the david/remove-callable-optimization branch from f87e255 to b679d98 Compare February 19, 2026 14:09
@sharkdp sharkdp changed the title [ty] Remove NoReturn shortcut optimization [ty] Completely remove the NoReturn shortcut optimization Feb 19, 2026
@sharkdp sharkdp changed the base branch from main to david/generic-fn-returns-never February 19, 2026 14:09
sharkdp added a commit that referenced this pull request Feb 20, 2026
## Summary

If a generic function's return type depends on a type variable, and the
argument passed resolves that type variable to `Never`, the call should
still be treated as terminal:

```py
def identity[T](x: T) -> T:
    return x

def f() -> Never:
    identity(exit())  # should be detected as terminal
```

This is a tiny win for correctness, but unfortunately a small drop in
performance and slight increase in memory usage, because we need to
infer more call expressions upfront. If we think it's not important
enough, I'm also okay to change this to a test-only PR that documents
this as a known limitation. If we merge it, I might follow up with an
idea to simplify the code in a [slightly larger refactoring
PR](#23378).

## Memory usage

Insignificant changes on some large internal projects, a 2% *decrease*
in memory usage when running on home-assistant/core.

## Ecosystem

No ecosystem changes, as far as I can tell.

## Test Plan

New Markdown tests
Base automatically changed from david/generic-fn-returns-never to main February 20, 2026 16:25
@sharkdp sharkdp force-pushed the david/remove-callable-optimization branch from b679d98 to 8ef8dbe Compare February 20, 2026 16:45
@sharkdp

This comment was marked as resolved.

knutwannheden pushed a commit to openrewrite/ruff that referenced this pull request Feb 20, 2026
## Summary

If a generic function's return type depends on a type variable, and the
argument passed resolves that type variable to `Never`, the call should
still be treated as terminal:

```py
def identity[T](x: T) -> T:
    return x

def f() -> Never:
    identity(exit())  # should be detected as terminal
```

This is a tiny win for correctness, but unfortunately a small drop in
performance and slight increase in memory usage, because we need to
infer more call expressions upfront. If we think it's not important
enough, I'm also okay to change this to a test-only PR that documents
this as a known limitation. If we merge it, I might follow up with an
idea to simplify the code in a [slightly larger refactoring
PR](astral-sh#23378).

## Memory usage

Insignificant changes on some large internal projects, a 2% *decrease*
in memory usage when running on home-assistant/core.

## Ecosystem

No ecosystem changes, as far as I can tell.

## Test Plan

New Markdown tests
@sharkdp sharkdp force-pushed the david/remove-callable-optimization branch 2 times, most recently from 62840c1 to 7836851 Compare February 23, 2026 12:22
@sharkdp

This comment was marked as outdated.

@sharkdp sharkdp force-pushed the david/remove-callable-optimization branch from 7836851 to 4eb2a72 Compare March 10, 2026 09:45
@sharkdp sharkdp marked this pull request as ready for review March 10, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants