Skip to content

Reduce TypeForm recognition slowdown: 2 identifier fast-reject filters - #21596

Closed
davidfstr wants to merge 5 commits into
python:masterfrom
davidfstr:f/typeform_complete--take3.1
Closed

davidfstr wants to merge 5 commits into
python:masterfrom
davidfstr:f/typeform_complete--take3.1

Conversation

@davidfstr

@davidfstr davidfstr commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

References #21262.

Includes 2 filters from the original combined 7-filter PR:

Summary

Enabling TypeForm made SemanticAnalyzer.try_parse_as_type_expression run
eagerly on every expression in certain syntactic positions (~2.84M calls per
self-check). <5% of those reach an expensive full-parse block
(expr_to_analyzed_type + isolated_error_analysis), and ~91% of
those fail — pure wasted work.

This PR adds 2 cheap, early-reject filters for the StrExpr-identifier
case, plus a reordering of the existing early-reject checks by decreasing
rejection frequency. Together the two filters eliminate 8% of full
parses (2,548 → 2,343) per self-check.

3 commits, all operating within the str_value.isidentifier()
branch of try_parse_as_type_expression:

  1. Filter A: isinstance(node, Var) + more conditions
  2. Reorder the 3 mutually-exclusive isinstance checks by decreasing rejection frequency.
  3. Filter B: isinstance(node, (FuncDef, OverloadedFuncDef, MypyFile))

Looking at the str_value.isidentifier() branch alone,
84% (244 → 39) of failing full parses are eliminated per self-check.

Performance

misc/perf_compare.py, single worker, paired per-round deltas.

CPU time (canonical, lowest-variance) — master vs branch tip, n=100

python misc/perf_compare.py --warmup-runs 3 --num-runs 100 -j 3 \
    --metric cpu --workers1 <master> <tip>
  • n=100: −7.6 ms ±5.9 (−0.28%)

Significant (CI excludes 0). This is the net effect of the branch.

Wall-clock — master vs branch tip

python misc/perf_compare.py --warmup-runs 3 --num-runs 400 -j 3 <master> <tip>
  • n=400: −3.6 ms ±4.3 (−0.26%), CI [−7.9, +0.7].

Borderline significant: the CI just barely includes 0. Consistent with a real
per-call win partly masked by multi-worker wall-clock.

Correctness

All tests pass.

Note that the var_is_typing_special_form helper needed to be extended to
recognize stringified forms of typing.Self so that Filter A would not
incorrectly reject a stringified 'Self' annotation, regressing the
testSelfRecognizedInOtherSyntacticLocations test.

Open Questions

  • Should the 2 infrastructure commits be moved to a separate PR? → Done
  • The full commit messages (after the subject line) on the 3 key commits
    are rather verbose. Let me know if you'd like me to trim them down,
    perhaps by removing everything after the subject.
    → Done

davidfstr and others added 5 commits June 5, 2026 13:16
Specifically:
* Median is reported, in addition to the existing mean+stdev, which is
  significantly more resistant to skew by outliers.
* --metric {wall,cpu} (default wall): Enables profiling using CPU time
  rather than wall-clock time. CPU profiling has roughly half the coefficient
  of variation as wall-clock profiling equal run count.
* --workers1: Forces MYPY_NUM_WORKERS=1 (rather than the default 4) to
  cut CPU scheduling variance. Strongly recommended when using --metric cpu.
* --warmup-runs N (default 1): Configurable number of leading cold runs to discard.
  Previously was always 1. Higher run counts decrease outliers that skew
  the reported mean.
* A new "Paired deltas vs <first commit>" section is added to the report,
  showing per-round paired differencing against the first commit
  to cancel round-level common-mode noise, reducing variance.
  Reported as median +/-95% CI.

Also:
* --cache-binaries (default false): Caches each commit's compiled clone
  to avoid ~5min recompile whenever comparing the same commit multiple times.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_parse_as_type_expression()

Specifically:

- If you set MYPY_TYPEFORM_PROFILE_FULL_PARSE environment variable,
  mypy will output a .tsv to that filepath which characterizes the
  kinds of Expressions that try_parse_as_type_expression() in semanal.py
  was forced to do a full parse of, which was not rejected early.

- A misc/analyze_typeform_full_parse_profile.py script is added which
  takes those .tsvs and prints an expression-time summary (by total time)
  plus top-N descriptors per FAIL class.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s_type_expression()

Add a fast-rejection filter to SemanticAnalyzer.try_parse_as_type_expression():
a string literal that is an identifier naming a Var whose declared type is a
concrete Instance (and is not a typing special form) is a value -- a local,
parameter, or module-level constant -- never a type expression. Reject it
before the expensive full-parse block (expr_to_analyzed_type +
isolated_error_analysis).

On the mypy self-check this filter rejects 157 of the 381 identifier-string
literals that currently reach the full-parse block (e.g. "__doc__", "__name__",
enum/constant members like "ROUND_DOWN", "GEN_CREATED"), all of which were
failing full parses -- pure wasted work.

Insertion point chosen empirically. The filter is placed AFTER the existing
PlaceholderNode and unbound-tvar checks rather than before them. Its only
expensive conjunct (get_proper_type(node.type)) runs solely for Var nodes, and
all Var nodes already survive both earlier checks, so position cannot change how
often the expensive part runs -- only how often the cheap isinstance(node, Var)
conjunct is evaluated. Because 951 of the identifier-strings reaching this block
are unbound type variables, evaluating the filter before the tvar check would
force an extra isinstance onto ~951 nodes it can never catch. perf_compare.py
(--metric cpu --workers1 --num-runs 100) confirms: paired median vs baseline was
-15.6ms +/-4.6 here, vs -12.9ms (before placeholder) and -9.3ms (before tvar) --
matching the eval-count model's predicted ordering.

Also add typing.Self / typing_extensions.Self to var_is_typing_special_form().
Self is a _SpecialForm-typed Var, so without this guard the new filter would
wrongly reject a stringified "Self" type annotation (regressing
testSelfRecognizedInOtherSyntacticLocations). This guard is a correctness
prerequisite of the filter and is committed together with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…frequency

Move the unbound-type-variable check ahead of the PlaceholderNode check (and
ahead of the Var-value filter added in the previous commit) in
SemanticAnalyzer.try_parse_as_type_expression(). Final order:

    unbound type variable  ->  value Var  ->  placeholder

These three checks are mutually exclusive -- a node is at most one of a
TypeVarExpr/ParamSpecExpr, a Var, or a PlaceholderNode -- so reordering cannot
change which expressions are rejected (verified: check-typeform and testsemanal
unchanged). Ordering them by descending rejection frequency, as measured on
mypy's self-check (unbound type vars ~951 >> value Vars ~157 > placeholders
~23), lets the commonest rejections exit first and minimizes total check
evaluations (~2700 -> ~1750 cheap isinstance calls over the self-check).

The win is below perf_compare's noise floor on its own (~10us), but the
reordering is free and behavior-preserving, and it makes the final ordering
self-documenting. A rationale comment is added at the head of the block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ression()

Add a second fast-rejection filter: a string literal that is an identifier
naming a FuncDef, OverloadedFuncDef, or MypyFile is a function or module, never
a type expression. Reject it before the expensive full-parse block.

On mypy's self-check this rejects 48 of the identifier-string literals that
reach the full-parse block (e.g. builtin functions like "classmethod",
"staticmethod", "hash"; user functions; module names like "platform"), all of
which were failing full parses.

Unlike the Var-value filter, this check is a single isinstance with no expensive
follow-on work, and FuncDef/OverloadedFuncDef/MypyFile are mutually exclusive
with the other early-reject node kinds, so it is freely positionable and its
rejection count is order-independent. It is placed by descending rejection
frequency: after the Var-value filter (~157) and before the placeholder check
(~23), i.e. the final order is

    unbound type variable (~951) -> value Var (~157)
        -> function/module (~48) -> placeholder (~23)

No companion guard is needed (a function or module name is never a valid type,
so nothing valid is rejected; check-typeform and testsemanal unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

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

@davidfstr

Copy link
Copy Markdown
Contributor Author

This PR is ready for review.

(Disregard if you already saw a notification when this PR was opened. I'm commenting explicitly just in case GH issue notifications weren't working at the time this PR was opened.)

@davidfstr

Copy link
Copy Markdown
Contributor Author

Obsoleted by #21833 and #21832

@davidfstr davidfstr closed this Aug 11, 2026
ilevkivskyi pushed a commit that referenced this pull request Sep 18, 2026
References #21262. Replaces #21585 and obsoletes #21596.

## Summary

Enabling `TypeForm` by default (referenced #21262) made
`SemanticAnalyzer.try_parse_as_type_expression` run eagerly on every
expression
in certain syntactic positions. The cost is concentrated in the
expensive
full-parse block (`expr_to_analyzed_type` + `isolated_error_analysis`),
which
**fails ~87% of the time** - pure wasted work.

This branch adds early-reject filters that eliminate **74% of full
parses
(2570 → 666)** on mypy's self-check, recovering **~46% of the
regression**:
**+1.57% → +0.84%** CPU time.

**No new regexes** - per review feedback on replaced #21585. Every
filter here
is plain string/`isinstance` work, and the two shape tests that were
regexes
are now helper functions.

<details>
<summary>Why not do a type-context check?</summary>

Review of replaced #21585 suggested skipping the call to 
`SemanticAnalyzer.try_parse_as_type_expression` entirely when the type
context
cannot be a `TypeForm`. That optimization already exists, but in the
other
*type checker* pass at `ExpressionChecker.try_parse_as_type_expression`.
The same skip cannot be used in the *semantic analyzer* pass's function
because the type context is not yet known.

So cheaply filtering the inputs to `SA.try_parse_as_type_expression` is
the
only remaining (obvious) lever to reduce its runtime contribution.

</details>

## Optimization Results

CPU time, single worker, paired per-round deltas, n=300:

```
python misc/perf_compare.py --warmup-runs 3 --num-runs 300 -j 3 \
    --metric cpu --workers1 \
    <TypeForm-disabled-commit> 5bb72b7 <tip-of-this-pr-branch>
```

| Commit | Mean | Median | Δ vs baseline (paired median ±95% CI) |
|---|---:|---:|---:|
| baseline, `<TypeForm-disabled-commit>` | 2.679 s | 2.676 s | - |
| master, `5bb72b788` | 2.720 s | 2.720 s | **+41.9 ms ±2.9 (+1.57%)** |
| all filters, `<tip-of-this-pr-branch>` | 2.703 s | 2.699 s | **+22.4
ms ±2.9 (+0.84%)** |

The feature branch recovers 
**19.5 ms of the 41.9 ms regression (~46% by paired median)** - 
leaving **+22.4 ms (~54%)**. Derivation:
- +41.9 ms - +22.4 ms == 19.5 ms recovered
-  19.5 ms / +41.9 ms == 46.5% (~46%) recovered
- +22.4 ms / +41.9 ms == 53.5% (~54%) left

A separate 2-way run of master vs `<tip-of-this-pr-branch>` measured
**−21.1 ms ±3.2**, consistent with the 19.5 ms recovered that was
derived above.

<details>
<summary>Notes on the measurement</summary>

The baseline (`<TypeForm-disabled-commit>`) is current master
(`5bb72b788`)
with referenced #21262 (SHA: `dd851f559`) reverted, so all three arms
share
today's code and differ only in `TypeForm`. Measuring against the
original
pre-#21262 master commit instead of today's master would have conflated
optimizations made during the following ~80 commits, including notably
`c0cced35c`,
which optimised `SA.try_parse_as_type_expression` specifically.

Thus runtime regression measured here (+41.9 ms) is smaller than the
+50.2 ms reported in replaced #21585: part of the original regression
has
already been absorbed upstream.

</details>

Full parses per self-check, identical corpus:

| | master | branch | |
|---|---:|---:|---|
| full parses | 2570 | 666 | **−74.1%** |
| - succeeded (produced a type) | 345 | 345 | **±0** ✅ |
| - failed (wasted work) | 2225 | 321 | **−85.6%** |

The successful-parse count is unchanged at every commit on the branch,
as expected: No expression that previously parsed as a type stopped
doing so.

## Overview of changes

- Most changes are made to the
`SemanticAnalyzer.try_parse_as_type_expression`
  function. All other changes occur within the same file.
- 5 commits, each individually profiled:
    - 4 commits add a filter
    - 1 commit reorders existing filters
- Any added filter can be dropped (if needed) without disturbing the
other filters

### The filter commits

Bare-identifier strings (`"Foo"`):

1. Reject a `Var` whose declared type is a concrete `Instance` - a
value, not a type.
2. Reject `FuncDef` / `OverloadedFuncDef` / `MypyFile` - functions and
modules are never types.
3. Reorder the mutually-exclusive checks by measured rejection
frequency.

Other strings:

4. Reject strings containing a character or boundary pattern that never
appears
in a type expression - leading/trailing `.`, or one of
``!:/<>@%$^?;&~`\``,
   or a `-` that is not a `Literal[...]` unary minus. Catches `"utf-8"`,
   `".pyi"`, `"error:"`, `"pkg/mod.py"`.
5. Dotted-name strings (`"builtins.tuple"`, `"typing.Mapping"`): look up
the
leftmost component and reject when it does not resolve, or resolves to a
   placeholder or a value `Var`.

Filters 4 and 5 replace `_NONTYPE_PATTERN_RE` and
`_DOTTED_IDENTIFIER_RE` from
replaced #21585 with the helpers `has_nontype_char()` and
`dotted_identifier_leftmost()`.
Each was verified to agree with the regex it replaces on all 1171
distinct
strings the full-parse profiler observes during a self-check.

<details>
<summary>Two specific hazards, and how they are handled</summary>

`var_is_typing_special_form` was extended to recognize `typing.Self` /
`typing_extensions.Self`, so filter 1 does not reject a stringified
`'Self'`
annotation (otherwise `testSelfRecognizedInOtherSyntacticLocations`
regresses).

In filter 4, `-` is treated as a unary minus wherever the preceding
non-space
character is `[` or `,`, so `"Literal[-1, -2]"` and `"Literal[1, -2]"`
are still
recognized. (`_NONTYPE_PATTERN_RE` in replaced #21585 used `(?<!\[)-`,
which rejected
those.) On the strings observed during a self-check the two rules reject
identical sets, so the (improved) soundness costs nothing.

</details>

## Notes

- I don't think it's worth trying to recover the remaining +22.4 ms:
- The 321 surviving failed parses are spread across four categories with
      no common cheap/obvious shape left
- I experimented with adding some fancy `OpExpr` filters that actually
      gave a net *slowdown* of 1.9ms.

- The profiling instrumentation and the `misc/perf_compare.py`
improvements used to produce these numbers are in a separate PR: #21832.
Happy to fold them in here instead if that is easier to review.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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