Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

beartype performance is substantially slower than no beartype #58

Closed
mpenning opened this issue Oct 8, 2021 · 13 comments
Closed

beartype performance is substantially slower than no beartype #58

mpenning opened this issue Oct 8, 2021 · 13 comments

Comments

@mpenning
Copy link

mpenning commented Oct 8, 2021

Hello,

I was quite interested when I found this stackoverflow answer about beartype... As a POC, I cooked up a performance test using beartype, Enthought traits, traitlets, and plain-ole-python-ducktyping...

But... I found that beartype was pretty slow in my test.... As an attempt to be as fair as possible, I used assert to enforce types in my duck-typed function (ref - main_duck_assert())...

I also confess that Enthought traits are compiled, so the Enthought traits data below is mostly just an FYI...

Running my comparison 100,000 times...

$ python test_type.py
timeit duck getattr time: 0.0395 seconds
timeit duck assert  time: 0.0417 seconds
timeit traits       time: 0.0633 seconds
timeit traitlets    time: 0.5236 seconds
timeit bear         time: 0.0782 seconds
$

Question
Am I doing something wrong with bear-typing (ref my POC code below)? Is there a way to improve the "beartyped" performance?

My rig...

  • Linux under VMWare (running on a Lenovo T430); kernel version 4.19.0-12-amd64
  • Python 3.7.0
  • beartype version 0.8.1
  • Enthought traits version 6.3.0
  • traitlets, version 5.1.0
from beartype import beartype

from traits.api import HasTraits as eHasTraits
from traits.api import Unicode as eUnicode
from traits.api import Int as eInt

from traitlets import HasTraits as tHasTraitlets
from traitlets import Unicode as tUnicode
from traitlets import Integer as tInteger

from timeit import timeit

def main_duck_getattr(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args and getattr"""
    getattr(arg01, "capitalize")  # Type-checking with attributes
    getattr(arg02, "to_bytes")    # Type-checking with attributes

    str_len = len(arg01) + arg02
    getattr(str_len, "to_bytes")
    return ("duck_bar", str_len,)

def main_duck_assert(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args and assert"""
    assert isinstance(arg01, str)
    assert isinstance(arg02, int)

    str_len = len(arg01) + arg02
    assert isinstance(str_len, int)
    return ("duck_bar", str_len,)


class MainTraits(eHasTraits):
    """Proof of concept code implenting Enthought traits args"""
    arg01 = eUnicode()
    arg02 = eInt()
    def __init__(self, *args, **kwargs):
        super(MainTraits, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("traits_bar", self.str_len)

class MainTraitlets(tHasTraitlets):
    """Proof of concept code implenting traitlets args"""
    arg01 = tUnicode()
    arg02 = tInteger()
    def __init__(self, *args, **kwargs):
        super(MainTraitlets, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("traitlets_bar", self.str_len)

@beartype
def main_bear(arg01: str="__undefined__", arg02: int=0) -> tuple:
    """Proof of concept code implenting bear-typed args"""
    str_len = len(arg01) + arg02
    return ("bear_bar", str_len,)

if __name__=="__main__":
    num_loops = 100000

    duck_result_getattr = timeit('main_duck_getattr("foo", 1)', setup="from __main__ import main_duck_getattr", number=num_loops)
    print("timeit duck getattr time:", round(duck_result_getattr, 4), "seconds")

    duck_result_assert = timeit('main_duck_assert("foo", 1)', setup="from __main__ import main_duck_assert", number=num_loops)
    print("timeit duck assert  time:", round(duck_result_assert, 4), "seconds")

    traits_result = timeit('mm.run("foo", 1)', setup="from __main__ import MainTraits;mm = MainTraits()", number=num_loops)
    print("timeit traits       time:", round(traits_result, 4), "seconds")

    traitlets_result = timeit('tt.run("foo", 1)', setup="from __main__ import MainTraitlets;tt = MainTraitlets()", number=num_loops)
    print("timeit traitlets    time:", round(traitlets_result, 4), "seconds")

    bear_result = timeit('main_bear("foo", 1)', setup="from __main__ import main_bear", number=num_loops)
    print("timeit bear         time:", round(bear_result, 4), "seconds")
@leycec
Copy link
Member

leycec commented Oct 9, 2021

I found that beartype was much slower than no bear-typing in my test....

Hah-hah! I love fielding questions like this, because overly scrupulous fixation on efficiency is my middle name(s).

Thankfully, according to the wizened sages of old and our own timeit timings, @beartype is still as blazing fast at call time as it always was. In general, @beartype adds anywhere from 1µsec (i.e., 10-6 seconds) in the worst case to 0.01µsec (i.e., 10-8 seconds) in the best case of call-time overhead to each decorated callable. This superficially seems reasonable – but is it?

Let's delve deeper.

Formulaic Formulas: They're Back in Fashion

First, let's formalize how exactly we arrive at the call-time overheads above.

Given any pair of reasonably fair timings (which yours absolutely are) between an undecorated callable and its equivalent @beartype-decorated callable, let:

  • n be the number of times (i.e., loop iterations) each callable is repetitiously called.
  • γ be the total time in seconds of all calls to that undecorated callable.
  • λ be the total time in seconds of all calls to that @beartype-decorated callable.

Then the call-time overhead Δ(n, γ, λ) added by @beartype to each call is:

Δ(n, γ, λ) = λ/n - γ/n

Plugging in n = 100000, γ = 0.0435s, and λ = 0.0823s from your excellent timings, we see that @beartype on average adds call-time overhead of 0.388µsec to each decorated call: e.g.,

Δ(100000, 0.0435s, 0.0823s) = 0.0823s/100000 - 0.0435s/100000
Δ(100000, 0.0435s, 0.0823s) = 3.8800000000000003e-07s

Again, this superficially seems reasonable – but is it? Let's delve deeper.

Function Call Overhead: The New Glass Ceiling

Next, the added cost of calling @beartype-decorated callables is a residual artifact of the added cost of stack frames (i.e., function and method calls) in Python. The mere act of calling any pure-Python callable adds a measurable overhead – even if the body of that callable is just a noop doing absolutely nothing. This is the minimal cost of Python function calls.

Since Python decorators almost always add at least one additional stack frame (typically as a closure call) to the call stack of each decorated call, this measurable overhead is the minimal cost of doing business with Python decorators. Even the fastest possible Python decorator necessarily pays that cost.

Our quandary thus becomes: "Is 1—0.01µsec of call-time overhead reasonable or is this sufficiently embarrassing as to bring multigenerational shame upon our entire extended family tree, including that second cousin twice-removed who never sends a kitsch greeting card featuring Santa playing with mischievous kittens at Christmas time?"

We can answer that by first inspecting the theoretical maximum efficiency for a pure-Python decorator that performs minimal work by wrapping the decorated callable with a closure that just defers to the decorated callable. This excludes the identity decorator (i.e., decorator that merely returns the decorated callable unmodified), which doesn't actually perform any work whatsoever. The fastest meaningful pure-Python decorator is thus:

def fastest_decorator(func):
    def fastest_wrapper(*args, **kwargs): return func(*args, **kwargs)
    return fastest_wrapper

By replacing @beartype with @fastest_decorator in your awesome snippet, we can expose the minimal cost of Python decoration:

$ python3.7 <<EOF
from timeit import timeit
def fastest_decorator(func):
    def fastest_wrapper(*args, **kwargs): return func(*args, **kwargs)
    return fastest_wrapper

@fastest_decorator
def main_decorated(arg01: str="__undefined__", arg02: int=0) -> tuple:
    """Proof of concept code implenting bear-typed args"""
    assert isinstance(arg01, str)
    assert isinstance(arg02, int)

    str_len = len(arg01) + arg02
    assert isinstance(str_len, int)
    return ("bear_bar", str_len,)

def main_undecorated(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args"""
    assert isinstance(arg01, str)
    assert isinstance(arg02, int)

    str_len = len(arg01) + arg02
    assert isinstance(str_len, int)
    return ("duck_bar", str_len,)

if __name__=="__main__":
    num_loops = 100000

    decorated_result = timeit('main_decorated("foo", 1)', setup="from __main__ import main_decorated", number=num_loops)
    print("timeit decorated time:  ", round(decorated_result, 4), "seconds")    
                                   
    undecorated_result = timeit('main_undecorated("foo", 1)', setup="from __main__ import main_undecorated", number=num_loops)
    print("timeit undecorated time:", round(undecorated_result, 4), "seconds")    
EOF
timeit decorated time:   0.1185 seconds
timeit undecorated time: 0.0889 seconds

Again, plugging in n = 100000, γ = 0.0889s, and λ = 0.1185s from your excellent timings, we see that @fastest_decorator on average adds call-time overhead of 0.3µsec to each decorated call: e.g.,

Δ(100000, 0.0889s, 0.1185s) = 0.1185s/100000 - 0.0889s/100000
Δ(100000, 0.0889s, 0.1185s) = 2.959999999999998e-07s

Holy Balls of Flaming Dumpster Fires

Holy balls, people. I'm actually astounded myself.

Above, we saw that @beartype on average only adds call-time overhead of 0.388µsec to each decorated call. But 0.388µsec - 0.3µsec = 0.088µsec, so @beartype only adds 0.1µsec (generously rounding up) of additional call-time overhead above and beyond that necessarily added by the fastest possible Python decorator.

Not only is @beartype within the same order of magnitude as the fastest possible Python decorator, it's effectively indistinguishable from the fastest possible Python decorator on a per-call basis.

Of course, even a negligible time delta accumulated over 10,000 function calls becomes slightly less negligible. Still, it's pretty clear that @beartype remains the fastest possible runtime type-checker for now and all eternity. Amen.

But, but... That's Not Good Enough!

Yeah. None of us are pleased with the performance of the official CPython interpreter anymore, are we? CPython is that geriatric old man down the street that everyone puts up with because they've seen Pixar's Up! and he means well and he didn't really mean to beat your equally geriatric 20-year-old tomcat with a cane last week. Really, that cat had it comin'.

If @beartype still isn't ludicrously speedy enough for you under CPython, we also officially support PyPy3 – where you're likely to extract even more ludicrous speed.

Does that fully satisfy your thirsty cravings for break-neck performance? If so, feel free to toggle that Close button. If not, I'd be happy to hash it out over a casual presentation of further timings, fake math, and Unicode abuse.

tl;dr

@beartype (and every other runtime type checker) will always be negligibly slower than hard-coded inlined runtime type-checking, thanks to the negligible (but surprisingly high) cost of Python function calls. Where this is unacceptable, PyPy3 is your code's new BFFL.

@mpenning
Copy link
Author

mpenning commented Oct 9, 2021

Thank you for a great analysis of the situation... as a retrospective, I am taking away the following from this exercise...

  1. If I don't want a compiled module and I don't care about PEP 484 Type Hints, duck typing is as fast as we can get (see main_duck_assert() and main_duck_getattr() above). Interestingly, duck typing also beat the compiled Enthought traits module... I confess this compares apples to oranges.
  2. If I'm willing to compile and I don't care about PEP 484, Enthought traits is the "safest" static typing approach (because variables are never allowed to deviate, compared with duck-typing approach above - which is a one-time type check). As mentioned above, Enthought traits is significantly slower than duck-type approaches.
  3. traitlets (pure python) has awful performance. Enthought traits is much faster (as long as a compiler is available).
  4. If I want PEP 484 Type Hints, the beartype decorator is a great option, but beartype is admittedly slower than non-PEP 484 approaches.

Summary: Enforcing PEP 484 slows things down. Duck typing + assert is as fast as we can get. If I must have PEP 484, beartype is the fastest option I've found.

Disclaimer: mypy was not considered... yet.

@mpenning mpenning closed this as completed Oct 9, 2021
@leycec
Copy link
Member

leycec commented Oct 9, 2021

These continue to be great questions! Thanks for all the fun engagement here. Let's see if I can't tackle a few of these...

If I don't want a compiled module and I don't care about PEP 484 Type Hints, duck typing is as fast as we can get (see main_duck_assert() and main_duck_getattr() above).

Right. All else being equal, manually inlining code (e.g., with hand-rolled assert() statements in this case) into a callable should always be slightly faster than decorating that callable with wrapper code.

The same is actually true of all languages – not only high-level dynamically typed languages like Python and Ruby. This is why manual loop unrolling is still a thing in C and C++. i am shaking my head

Interestingly, duck typing also beat the compiled Enthought traits module...

Right. This is because Enthought traits isn't actually being compiled to low-level machine code; like all pure-Python code, CPython is just byte-compiling traits into intermediary Python bytecode. But bytecode is still excruciatingly slow, sadly.

Given your understandable interest in extreme speed, compilation, and optimizations, I wonder if you're familiar with pydantic? Unlike traits, pydantic is actually being compiled to low-level machine code via Cython. This means that pydantic should be faster than duck typing.

The only disadvantage of pydantic's approach is that it will be slower than beartype's approach when JIT-ed by a JIT like PyPy3, because JITs like PyPy3 can't JIT low-level Cython, C, or machine code; they can only JIT pure-Python.

In any case... I now, right? It's hard to beat the duck, but pydantic should get you there for 90% of common use cases. 🦆

Enthought traits is the "safest" static typing approach...

Careful there! "Static" is an unfortunately overloaded term that means many strange different things in many strange different languages.

When Python devs say "static typing," they mean static type checkers that perform their work at static analysis time rather than runtime. So, things like mypy, Facebook's Pyre, Microsoft's pyright, and Google's pytype is what I'm saying.

What you probably mean here is more commonly known as type-safe dataclasses. This is also what pydantic and attrs (and eventually beartype itself) do: they inject automated type-checking into each read and write of class and instance variables.

traitlets (pure python) has awful performance. Enthought traits is much faster (as long as a compiler is available).

Fascinating! Again, pydantic should handily beat out both if you have Cython installed. Enthought traits and traitlets are actually both pure-Python, as far as I know. If Enthought traits significantly outperforms traitlets, this is likely due to extreme microoptimizations in the former versus the latter.

Microoptimization is right up in our wheelhouse here, because beartype itself internally leverages extreme microoptimization to the hilt to extract every last ounce of the wall-clock time slice from CPython.

If I want PEP 484 Type Hints, the beartype decorator is a great option, but beartype is admittedly slower than non-PEP 484 approaches.

Actually... beartype should be dramatically faster than all other pure-Python runtime type checkers! This includes Enthought traits, traitlets, and attrs. If this is not the case, please post timings in a separate issue and I'd be delighted to repeatedly bang my head against CPython until we beat 'em up. 🤜 🩸 🤛

Beartype actually began life as a non-PEP 484 type checker. For this reason, @beartype reduces simple use cases that only type check against simple classes to an optimally efficient code path. For these use cases, @beartype entirely avoids all heavyweight compliance with PEP standards – including PEP 484.

If you're at all curious about what the code @beartype generates vaguely looks like, hit this link when you find a spare moment. We actually generate even faster code than when that link was first published, but it's still a useful deep dive into the Beartype School of Hard Knocks.

Thanks again for the fun discourse, Mike! From the Great White North to you, have a great Canadian Thanksgiving. 🦃 🍴

@mpenning
Copy link
Author

mpenning commented Oct 11, 2021

Given your understandable interest in extreme speed, compilation, and optimizations, I wonder if you're familiar with pydantic? Unlike traits, pydantic is actually being compiled to low-level machine code via Cython. This means that pydantic should be faster than duck typing.

Thank you for pointing out pydantic, but pydantic tested worst of all when I configured type validation... see the test_me.py output below...

Enthought traits and traitlets are actually both pure-Python, as far as I know.

Actually Enthought traits compiles the base traits classes... also ref the source -> traits/ctraits.c... quoting from the docs...

traits.ctraits Module
Fast base classes for HasTraits and CTrait.

The ctraits module defines the CHasTraits and cTrait extension types that define the core performance-oriented portions of the Traits package.

My rig...

Test results...

$ python test_me.py
timeit duck getattr time: 0.0432 seconds
timeit duck assert  time: 0.0466 seconds
timeit traits       time: 0.0743 seconds
timeit bear         time: 0.098 seconds
timeit traitlets    time: 0.5947 seconds
timeit pydantic     time: 1.6075 seconds

Test code:

# Filename: test_me.py
from beartype import beartype

import pydantic

from traits.api import HasTraits as eHasTraits
from traits.api import Unicode as eUnicode
from traits.api import Int as eInt

from traitlets import HasTraits as tHasTraitlets
from traitlets import Unicode as tUnicode
from traitlets import Integer as tInteger

from timeit import timeit

###############################################################################
# define duck type getattr() test function
###############################################################################

def main_duck_getattr(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args and getattr"""
    getattr(arg01, "capitalize")  # Type-checking with attributes
    getattr(arg02, "to_bytes")    # Type-checking with attributes

    str_len = len(arg01) + arg02
    getattr(str_len, "to_bytes")
    return ("duck_bar", str_len,)

###############################################################################
# define duck type assert test function
###############################################################################

def main_duck_assert(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args and assert"""
    assert isinstance(arg01, str)
    assert isinstance(arg02, int)

    str_len = len(arg01) + arg02
    assert isinstance(str_len, int)
    return ("duck_bar", str_len,)

###############################################################################
# define Enthought traits test class
###############################################################################

class MainTraits(eHasTraits):
    """Proof of concept code implenting Enthought traits args"""
    arg01 = eUnicode()
    arg02 = eInt()

    def __init__(self, *args, **kwargs):
        super(MainTraits, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("traits_bar", self.str_len)

###############################################################################
# define traitlets test class
###############################################################################

class MainTraitlets(tHasTraitlets):
    """Proof of concept code implenting traitlets args"""
    arg01 = tUnicode()
    arg02 = tInteger()

    def __init__(self, *args, **kwargs):
        super(MainTraitlets, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("traitlets_bar", self.str_len)

###############################################################################
# define beartype test function
###############################################################################

@beartype
def main_bear(arg01: str="__undefined__", arg02: int=0) -> tuple:
    """Proof of concept code implenting bear-typed args"""
    str_len = len(arg01) + arg02
    return ("bear_bar", str_len,)

###############################################################################
# define pydantic test class
###############################################################################

class MainPydantic(pydantic.BaseModel):
    """
    Proof of concept code implenting pydantic args

    - Warning: pydantic does NOT vaidate types by default
      https://github.com/samuelcolvin/pydantic/issues/578
    """
    arg01: pydantic.StrictStr = ""
    arg02: pydantic.StrictInt = 0
    str_len: pydantic.StrictInt = 0

    class Config(object):
        """Configure pydantic parameters"""
        validate_all = True
        validate_assignment = True

    def __init__(self, *args, **kwargs):
        super(MainPydantic, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("pydantic_bar", self.str_len)

if __name__=="__main__":
    num_loops = 100000

    duck_result_getattr = timeit('main_duck_getattr("foo", 1)', setup="from __main__ import main_duck_getattr", number=num_loops)
    print("timeit duck getattr time:", round(duck_result_getattr, 4), "seconds")

    duck_result_assert = timeit('main_duck_assert("foo", 1)', setup="from __main__ import main_duck_assert", number=num_loops)
    print("timeit duck assert  time:", round(duck_result_assert, 4), "seconds")

    traits_result = timeit('mm.run("foo", 1)', setup="from __main__ import MainTraits;mm = MainTraits()", number=num_loops)
    print("timeit traits       time:", round(traits_result, 4), "seconds")

    bear_result = timeit('main_bear("foo", 1)', setup="from __main__ import main_bear", number=num_loops)
    print("timeit bear         time:", round(bear_result, 4), "seconds")

    traitlets_result = timeit('tt.run("foo", 1)', setup="from __main__ import MainTraitlets;tt = MainTraitlets()", number=num_loops)
    print("timeit traitlets    time:", round(traitlets_result, 4), "seconds")

    pydantic_result = timeit('pp.run("foo", 1)', setup="from __main__ import MainPydantic;pp = MainPydantic()", number=num_loops)
    print("timeit pydantic     time:", round(pydantic_result, 4), "seconds")

@leycec
Copy link
Member

leycec commented Oct 11, 2021

Wow! Thanks so much for your thoughtful corrections on Enthought traits and that extensive cross-type checker profiling suite. This is all mega-helpful and amazing stuff right here, which I'll now forcefully meld somehow into our existing but much less amazing test suite.

Mike Penning is... The Optimization Boss.

Thank you for pointing out pydantic, but pydantic tested worst of all when I configured type validation... see the test_me.py output below...

Poor, poor pydantic. I now feel sorrow and shame for them. That's an unfortunate and surprising showing for a validation framework widely utilized by the web dev community in general and FastAPI specifically. I mean, it's called FastAPI for a reason – right?

My only useful thought here is that pydantic might not have been Cythonized under your Python interpreter? As far as I recall, pydantic's Cythonization is fully optional at installation time and only enabled if certain conditions beyond my feeble mind are met. Because you've accounted for everything, you've accounted for that, too. But... it's worth a second look.

If pydantic was indeed Cythonized under your Python interpreter, that would be both baffling and sad. I mean, what's the point of Cythonizing (which is no trivial thing) if you can't even hit microoptimized pure-Python speeds? Yikes.

Actually Enthought traits compiles the base traits classes... also ref the source -> traits/ctraits.c... quoting from the docs...

OMG. traits/ctraits.c is the most intense Python validation code I've ever seen. It's just thousands and thousands of lines of hardcore low-level fragile C that just keeps endlessly going on and on. That's fanatically impressive work on Enthought's part. Two monstrous thumbs up to the Enthought team for their dedication to ludicrously optimized runtime QA.

Just... wowzers. I'm honestly stunned.

It's also fascinating that the pure-Python beartype is only slightly slower than the C-optimized traits. I kinda expected more dramatic speedups from Enthought's full-throttle investment into C. I mean, traits is faster than beartype – it better be! But it's not that much faster to warrant rewriting everything in hand-rolled C over. The performance gap between traits and beartype is much narrower than, say, the performance gap between beartype and traitlets or between traitlets and pydantic.

Do you have PyPy3 installed by any chance? You've already gone well above and beyond the call of profiling duty. But... it would be equally fascinating to see how everything fares under PyPy3. In theory, PyPy3 should narrow the performance gap between these six heroic contenders even further.

I'm on the edge of my seat here, Mike. Which approach (excluding the two ducking baselines, which should still outperform everything else) will survive this bloody gladiatorial combat? I'm betting on you, fighting grizzly bear!

🏟️ 🐻 ⚔️ 💦 🩸

@mpenning
Copy link
Author

My only useful thought here is that pydantic might not have been Cythonized under your Python interpreter? ... My only useful thought here is that pydantic might not have been Cythonized under your Python interpreter? ... If pydantic was indeed Cythonized under your Python interpreter, that would be both baffling and sad. I mean, what's the point of Cythonizing (which is no trivial thing) if you can't even hit microoptimized pure-Python speeds? Yikes.

Good question, and fortunately pydantic supplies a hook to quickly check this... pydantic.compiled returns a boolean... I looked and indeed pydantic was compiled in my virtualenv... I took the liberty of updating my local copy of test_me.py to fail if pydantic wasn't compiled...

It's also fascinating that the pure-Python beartype is only slightly slower than the C-optimized traits. I kinda expected more dramatic speedups from Enthought's full-throttle investment into C

Well that seems like a testimony of your good work to make beartype perform well.

The traits cases aren't easy to characterize for sure... Enthought traits performance is alright if we only validate types... validating values in traits is just ugly (from a performance perspective)... for instance, I tried validating the str_len traits values with Range() and the whole traits performance story went upside down (performance-wise)...

from traits.api import Range as eRange
#...
str_len = eRange(low=0, high=80, trait=eInt)

That code makes the traits test case perform much worse than the beartype case.... I realize that value analysis falls outside the discussion about python typing performance, but it's also very likely that developers using Enthought traits will also check values with traits.

Do you have PyPy3 installed by any chance?

I just downloaded the latest pypy3 tarball... it's late and I would like to defer more analysis for another time :-)

And I think it's worth saying... You truly represent the open source community well... I don't think I've seen such a lively and engaging discussion in other projects. Props to you!

@mpenning
Copy link
Author

mpenning commented Oct 12, 2021

FYI... my latest copy of the type eval tests... in retrospect, I probably should have put this in git revision control... something for another day...

# Filename: test_me.py
from beartype import beartype

import pydantic

from traits.api import HasTraits as eHasTraits
from traits.api import Unicode as eUnicode
from traits.api import Int as eInt
from traits.api import Range as eRange
from traits.api import Disallow as eDisallow

from traitlets import HasTraits as tHasTraitlets
from traitlets import Unicode as tUnicode
from traitlets import Integer as tInteger

from timeit import timeit

###############################################################################
# define duck type getattr() test function
###############################################################################

def main_duck_getattr(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args and getattr"""
    getattr(arg01, "capitalize")  # Type-checking with attributes
    getattr(arg02, "to_bytes")    # Type-checking with attributes

    str_len = len(arg01) + arg02
    getattr(str_len, "to_bytes")
    return ("duck_bar", str_len,)

###############################################################################
# define duck type assert test function
###############################################################################

def main_duck_assert(arg01="__undefined__", arg02=0):
    """Proof of concept code implenting duck-typed args and assert"""
    assert isinstance(arg01, str)
    assert isinstance(arg02, int)

    str_len = len(arg01) + arg02
    assert isinstance(str_len, int)
    return ("duck_bar", str_len,)

###############################################################################
# define Enthought traits test class
###############################################################################

class MainTraits(eHasTraits):
    """Proof of concept code implenting Enthought traits args"""
    arg01 = eUnicode
    arg02 = eInt
    #str_len = eRange(low=0, high=80, trait=eInt)
    str_len = eInt
    # disallow other variables unless they are explicitly called out here...
    _       = eDisallow

    def __init__(self, *args, **kwargs):
        super(MainTraits, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("traits_bar", self.str_len)

###############################################################################
# define traitlets test class
###############################################################################

class MainTraitlets(tHasTraitlets):
    """Proof of concept code implenting traitlets args"""
    arg01 = tUnicode()
    arg02 = tInteger()
    str_len = tInteger()

    def __init__(self, *args, **kwargs):
        super(MainTraitlets, self).__init__(*args, **kwargs)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("traitlets_bar", self.str_len)

###############################################################################
# define beartype test function
###############################################################################

@beartype
def main_bear(arg01: str="__undefined__", arg02: int=0) -> tuple:
    """Proof of concept code implenting bear-typed args"""
    str_len = len(arg01) + arg02
    return ("bear_bar", str_len,)

###############################################################################
# define pydantic test class
###############################################################################

class MainPydantic(pydantic.BaseModel):
    """
    Proof of concept code implenting pydantic args

    - Warning: pydantic does NOT validate types by default
      https://github.com/samuelcolvin/pydantic/issues/578
    """
    arg01: pydantic.StrictStr = ""
    arg02: pydantic.StrictInt = 0
    str_len: pydantic.StrictInt = 0

    class Config(object):
        """Configure pydantic validation parameters"""
        validate_all = True
        validate_assignment = True

    def __init__(self, *args, **kwargs):
        super(MainPydantic, self).__init__(*args, **kwargs)
        if pydantic.compiled is False:
            error = ("During installation, the pydantic module was not"
                " compiled with cython")
            raise SystemError(error)

    def run(self, arg01="__undefined__", arg02=0):
        self.arg01 = arg01
        self.arg02 = arg02
        self.str_len = len(self.arg01) + self.arg02
        return ("pydantic_bar", self.str_len)

if __name__=="__main__":
    num_loops = 100000

    duck_result_getattr = timeit('main_duck_getattr("foo", 1)', setup="from __main__ import main_duck_getattr", number=num_loops)
    print("timeit duck getattr time:", round(duck_result_getattr, 4), "seconds")

    duck_result_assert = timeit('main_duck_assert("foo", 1)', setup="from __main__ import main_duck_assert", number=num_loops)
    print("timeit duck assert  time:", round(duck_result_assert, 4), "seconds")

    traits_result = timeit('mm.run("foo", 1)', setup="from __main__ import MainTraits;mm = MainTraits()", number=num_loops)
    print("timeit traits       time:", round(traits_result, 4), "seconds")

    bear_result = timeit('main_bear("foo", 1)', setup="from __main__ import main_bear", number=num_loops)
    print("timeit bear         time:", round(bear_result, 4), "seconds")

    traitlets_result = timeit('tt.run("foo", 1)', setup="from __main__ import MainTraitlets;tt = MainTraitlets()", number=num_loops)
    print("timeit traitlets    time:", round(traitlets_result, 4), "seconds")

    pydantic_result = timeit('pp.run("foo", 1)', setup="from __main__ import MainPydantic;pp = MainPydantic()", number=num_loops)
    print("timeit pydantic     time:", round(pydantic_result, 4), "seconds")

@leycec
Copy link
Member

leycec commented Oct 12, 2021

So impressive. That's some seriously industrial-strength profiling. I'm now considering jettisoning our shoddy Bash-based profiling suite in favour of... exactly what you've done!

Hypothetically speaking, would you strenuously object to my importing your test_me suite into @beartype somewhere? I will publicly lavish you and your GitHub username with praise in the code itself and throughout our documentation (which admittedly only consists of a single 4,773 lines-long README.rst file).

Two approaches avail us – one that grants you co-ownership over everything and the other that grants you a welcome respite from odorous responsibilities:

  • Co-ownership approach. I create a new beartype/beartime GitHub repository licensed under the same MIT license (...or whatever you're most comfortable with), grant you administrator access, and then let you have your vigorous way with it. beartime would be:
    • An entirely separate project devoted exclusively to profiling runtime type checkers and validators – which I'd be extremely grateful for your continued assistance in developing, documenting, reviewing incoming PRs, and resolving incoming issues.
    • Entirely agnostic of beartype itself and only housed under the umbrella @beartype organization for project visibility, promotion, and convenience. That is to say, associating beartime with beartype would probably be to everyone's benefit – even when beartime shows beartype to "underperform" competing projects. The ugly but unvarnished truth is what we're always after here.
    • Installable as its own distinct beartime package with both pip and conda. beartime would have its own setup.py (...or pypackage.toml, if you prefer), which would either:
      • Require as mandatory runtime dependencies beartime, pydantic, traits, traitlets, and {insert_typechecker_package_name_here}.
      • Require no mandatory runtime dependencies, but instead conditionally skip profiling type checkers not installed under the active Python interpreter with a non-fatal warning (...or something, something).
  • Pull request approach. I replace our existing bin/profile.bash profiling suite embedded directly in this beartype/beartype repository with your profiling suite also embedded directly in this beartype/beartype repository, renamed something suitably unambiguous like a top-level beartype_time/ subdirectory (...or something, something). Whenever you submit a PR against beartype_time/, I just blindly merge it under the working assumption that you're awesome and know what you're doing better than I do – because you clearly do.

Both definitely work. The latter's preferable if you're scarce on free time, volunteer enthusiasm, and the oppressive desire to shame our online rivals; the former's preferable if you're burning with a feverish need to minutely control, micromanage, and fine-tune every aspect of your own transformative genius.

Or we could just do nothing and pretend this never happened like that one time our Maine Coon cat mewled incessantly for three hours preceding dinner time (...that was an ugly evening). Avoiding work works, too – but would sadden and depress me. Let's do bold and risky things instead. 👯‍♀️ 👯‍♀️ 👯‍♀️

@leycec
Copy link
Member

leycec commented Oct 12, 2021

Relatedly, this obsessively fascinates me...

I tried validating the str_len traits values with Range() and the whole traits performance story went upside down (performance-wise)...

Hah-hah! Suck it, traits. Suck it. But seriously – beartype actually has a competing validation API that should let us profile a fair apples-to-apples comparison between the two:

from beartype import beartype
from beartype.vale import Is
from typing import Annotated   # <--------------- if Python ≥ 3.9.0
#from typing_extensions import Annotated   # <-- if Python < 3.9.0

# Beartype's equivalent of the traits eRange() validator defined above.
bear_str_len = Annotated[str, Is[lambda text: 0 <= len(text) <= 80]

@beartype
def main_bear(arg01: bear_str_len="__undefined__", arg02: int=0) -> tuple:
    """Proof of concept code implenting bear-typed args"""
    str_len = len(arg01) + arg02
    return ("bear_bar", str_len,)

What will happen? Who will win? I don't know, but I'm gripping my white-knuckled fist with uncertainty. My vaguely hand-wavy suspicion is that traits should come back out on top, because the particular beartype validator invoked above (i.e., beartype.vale.Is) adds one additional stack frame to each function call, because that validator necessarily defers to the user-declared lambda function subscripting (indexing) that validator.

Oh, boy. This gettin' gud. 🍿

@mpenning
Copy link
Author

Hypothetically speaking, would you strenuously object to my importing your test_me suite into @beartype somewhere

Thank you for your profuse appreciation... officially, I'm a Cisco Systems employee and pretty-much everything I do is copyright Cisco Systems. Obviously, IANAL and I need to ask before we have a final disposition on this code... please give me a few days to work out what officially Cisco wants me to do in this case.

@leycec
Copy link
Member

leycec commented Oct 13, 2021

...ohnoes

I didn't mean to publicly put you on the spot, Mike. Now I feel bad, having failed to grok the subtle signs that you were on the company dime. Awkward.

This is why I live in the woods, folks. We deal with simple Old World problems here – like how to politely separate an unruly pack of raccoons squabbling over post-Thanksgiving kitchen detritus without losing a hand. it happened

Please. Don't go to any absurd trouble on beartype's account. On the one hand, we'd be more than happy to publicly attribute, advertise, and/or promote Cisco Systems. On the other hand, it might be best for all the inevitable legal, HR, and marketing suits involved if I just manually rewrite our profiling suite from the ground up without reference to your formidable work.

I now formally swear on both of my arthritic pinky fingers that I neither read nor understood (...if I actually read, which I didn't!) a single line of the code you graciously posted above. I swear, Cisco. I didn't see nuffin'.

@mpenning
Copy link
Author

...ohnoes

Please don't worry... it's 100% my fault if there is a problem. I am not assuming there is or isn't a problem... Cisco has an open-source approval process... I formally asked for approval and we'll see what happens.

@leycec
Copy link
Member

leycec commented Oct 13, 2021

You've gone above and beyond the bear call of duty. Now, I can only inundate you with my prophetic memes.

bad idea is bad

relevant meme hurts me

yah, you said something

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

No branches or pull requests

2 participants