Skip to content

Don't clear expression caches inside lambda bodies - #22475

Open
Jahongir-Qurbonov wants to merge 1 commit into
python:masterfrom
Jahongir-Qurbonov:two-sided-inference-perf
Open

Jahongir-Qurbonov wants to merge 1 commit into
python:masterfrom
Jahongir-Qurbonov:two-sided-inference-perf

Conversation

@Jahongir-Qurbonov

Copy link
Copy Markdown
Contributor

Checking chained generic calls with lambda arguments is exponential, and #22394 made it noticeably worse. For example:

from typing import Callable, Optional, TypeVar

T = TypeVar("T")
S = TypeVar("S")

def r(xs: list[T], fn: Callable[[T], S]) -> list[Optional[S]]: ...

x: list[Optional[object]] = r(r(r(r(r(r(r(r(r([1], lambda a: a), lambda a: a), lambda a: a), lambda a: a), lambda a: a), lambda a: a), lambda a: a), lambda a: a), lambda a: a)

With 9 nested calls this visits the lambdas 29523 times (about 3^n). Before #22394 it was 4058 (about 2.4^n). With 9 levels it takes around 12s, and every extra level makes it about 3x slower.

The new two-sided inference re-infers all arguments once more when a call has a lambda argument. That would be fine, because the expression cache is supposed to make repeated checks of the same inner call in the same type context cheap. But the cache was never hit here. TypeChecker.visit_block() clears the expression caches after each statement, and a lambda body is checked as a block containing a return statement. So each time a lambda argument was checked, both the expression cache and the freshen_cache were cleared. After that, every re-check of an inner call had to start from scratch, and it also got freshly renamed type variables, so even its type contexts no longer matched old cache keys.

This PR skips clearing the caches for statements in a lambda body. The expression cache is never read or written while in_lambda_expr is set, so keeping its entries while a lambda is checked can't make them stale. The freshen_cache is meant to be reused when the same call is checked again within a statement, which is exactly what now happens for calls inside lambdas too. The caches are still cleared after the enclosing statement. The example above now visits the lambdas 51 times (n=5: 363 -> 27, n=7: 3279 -> 39, n=9: 29523 -> 51), so the cost is linear.

I added a test next to the existing expression cache tests. It checks an 8-level chain, and like those tests it also checks that type variable ids stay small. Without the fix the id reaches 26243 and the test takes about 14s.

The type variable ids in testInferenceAgainstGenericLambdas are now slightly smaller, because fewer redundant freshenings happen. The inferred types are the same. Nested lambdas, like m([1], lambda a: m([1], lambda b: ...)), are not affected, because the expression cache is disabled inside lambdas altogether.

Checking chained generic calls with lambda arguments, like
r(r(r([1], lambda a: a), lambda a: a), lambda a: a), is exponential
in the nesting depth. The two-sided inference from python#22394 re-infers all
arguments once more when there is a lambda argument, which raised the
number of lambda visits from about 2.4^n to 3^n (29523 visits and
~12s for n=9).

Repeated checks of the same inner call in the same type context should
be served by the expression cache, but it was never hit here: the
statement loop in visit_block() resets the expression checker caches
after each statement, and a lambda body is checked as a block with a
return statement. So checking any lambda argument wiped both the
expression cache and the freshen cache, and every re-check of an inner
call started from scratch (with freshly renamed type variables, so even
its type contexts did not match old cache keys).

Skip the reset for statements in a lambda body. The expression cache
is never read or written while in_lambda_expr is set, so its entries
cannot become stale there; the caches are still cleared after the
enclosing statement. The example above now visits lambdas 6n-3 times
(n=9: 29523 -> 51).

The ids of type variables in testInferenceAgainstGenericLambdas are
now slightly smaller, since fewer redundant freshenings happen; the
inferred types are unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

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.

1 participant