[Python-Dev] PEP 563: Postponed Evaluation of Annotations

Nick Coghlan ncoghlan at gmail.com
Fri Nov 3 02:22:36 EDT 2017

On 3 November 2017 at 03:00, Brett Cannon <brett at python.org> wrote:
> The cost of constructing some of the objects used as type hints can be very
> expensive and make importing really expensive (this has been pointed out by
> Lukasz previously as well as Inada-san). By making Python itself not have to
> construct objects from e.g. the 'typing' module at runtime, you then don't
> pay a runtime penalty for something you're almost never going to use at
> runtime anyway.

Another point worth noting is that merely importing the typing module
is expensive:

$ python -m perf timeit -s "from importlib import reload; import
typing" "reload(typing)"
Mean +- std dev: 10.6 ms +- 0.6 ms

10 ms is a *big* chunk out of a CLI application's startup time budget.

So I think to be truly effective in achieving its goals, the PEP will
also need to promote TYPE_CHECKING to a builtin, such that folks can

    from __future__ import lazy_annotations # More self-explanatory name
        import typing

and be able to express their type annotations in a way that a static
type checker will understand, while incurring near-zero runtime


Regarding the thunk idea: the compiler can pretty easily rewrite all
annotations from being "<expr>" to "lambda: <expr>".

This has a couple of very nice properties:

* rendering them later is purely a matter of calling them, since the
compiler will take care of capturing all the right namespaces and name
references (including references from method declarations to the type
defining them)
* quoted and unquoted annotations will reliably render differently
(since one will return a string when called, and the other won't)

The big downside to this approach is that it makes simple annotations
*more* expensive rather than less expensive.

Baseline (mentioning a builtin):
$ python -m perf timeit "str"
Mean +- std dev: 27.1 ns +- 1.4 ns

String constant (~4x speedup):
$ python -m perf timeit "'str'"
Mean +- std dev: 7.84 ns +- 0.22 ns

Lambda expression (~2x slowdown):
$ python -m perf timeit "lambda: str"
Mean +- std dev: 62.3 ns +- 1.4 ns

That said, I'll also point out the following:

* for application startup purposes, if you save 10 ms by not importing
the typing module, then that buys you time for around 285 *thousand*
implicit lambda declarations before your startup actually gets slower
(assuming the relative timings on my machine are typical)
* for nested functions, the overhead of the function call is enough
that the dramatic 4x speedup vs 2x slowdown ratio disappears (see P.S.
for numbers)
* I don't believe we've really invested much time in optimising the
creation of zero-argument lambdas yet, so there may be options for
bringing the numbers down for the lambda based approach

The other key downside to the lambda based approach is that it hits
the same backwards compatibility problem we hit when list
comprehensions were given their own nested scope: if we push
annotations down into a new scope, they won't be able to see class
level attributes any more. For comprehensions, we could partially
mitigate that by evaluating the outermost iterable expression in the
class scope, but there's no equivalent to that available for
annotations (since the annotation's lambda expression may never be
called at all).


P.S. Relative performance of the annotation styles in a nested
function definition

$ python -m perf timeit -s "def f(): str" "f()"
Mean +- std dev: 103 ns +- 3 ns

String constant (~1.25x speedup):
$ python -m perf timeit -s "def f(): 'str'" "f()"
Mean +- std dev: 77.0 ns +- 1.7 ns

Lambda expression (~1.5x slowdown):
$ python -m perf timeit -s "def f(): (lambda: str)" "f()"
Mean +- std dev: 149 ns +- 6 ns

Nick Coghlan   |   ncoghlan at gmail.com   |   Brisbane, Australia

More information about the Python-Dev mailing list