Как стать автором
Обновить

Python v3.x: как увеличить скорость декоратора без регистрации и смс

Время на прочтение 5 мин
Количество просмотров 6K
Вначале была эта статья. Потом к ней появился комментарий. А в результате я углубился в чтение матчасти, закопался в дебаг и смог оптимизировать код из первой части этой истории. Предлагаю вместе со мной пройтись по основным моментам.

Для начала хочу поблагодарить Mogost. Благодаря его комментарию я пересмотрел подход к Пайтону. Я и ранее слыхал о том, что среди пайтонистов достаточно много неэкономных ребят (при обращении с памятью), а теперь выяснилось, что я как-то незаметно для себя присоединился к этой тусовке.

Итак, начнем. Давайте порассуждаем, а какие вообще были узкие места.

Постоянные if:
if isinstance(self.custom_handlers, property):
if self.custom_handlers and e.__class__ in self.custom_handlers:
if e.__class__ not in self.exclude:


и это не предел. Поэтому часть if-ов я убрал, кое-что перенес в __init__, т.е. туда, где это будет вызвано один раз. Конкретно проверка на property в коде должна быть вызвана единоразово, т.к. декоратор применяется к методу и закрепляется за ним. И property класса, соответственно, останется неизменным. Поэтому и незачем проверять property постоянно.

Отдельный момент это if in. Профайлер показал, что на каждый такой in отдельный вызов, поэтому я решил все хэндлеры объединить в один dict. Это позволило избежать if-ов вообще, взамен используя просто:
self.handlers.get(e.__class__, Exception)(e)


таким образом в self.handlers у нас находится dict, который в качестве значения по умолчанию содержит функцию, рейзящую остальные исключения.

Отдельного внимания, конечно же, заслуживает wrapper. Это та самая функция, которая вызывается каждый раз, когда вызывается декоратор. Т.е. здесь лучше по максимуму избежать лишних проверок и всяких нагрузок, по возможности вынеся их в __init__ или в __call__. Вот какой wrapper был ранее:
def wrapper(self, *args, **kwargs):
        if self.custom_handlers:
            if isinstance(self.custom_handlers, property):
                self.custom_handlers = self.custom_handlers.__get__(self, self.__class__)

        if asyncio.iscoroutinefunction(self.func):
            return self._coroutine_exception_handler(*args, **kwargs)
        else:
            return self._sync_exception_handler(*args, **kwargs)


количество проверок зашкаливает. Это все будет вызываться на каждом вызове декоратора. Поэтому wrapper стал таким:
    def __call__(self, func):
        self.func = func

        if iscoroutinefunction(self.func):
            def wrapper(*args, **kwargs):
                return self._coroutine_exception_handler(*args, **kwargs)
        else:
            def wrapper(*args, **kwargs):
                return self._sync_exception_handler(*args, **kwargs)

        return wrapper


напомню, __call__ будет вызван один раз. Внутри __call__ мы в зависимости от степени асинхронности функции возвращаем саму функцию или корутин. И дополнительно хочу заметить, что asyncio.iscoroutinefunction делает дополнительный вызов, поэтому я перешел на inspect.iscoroutinefunction. Собственно, бенчи (cProfile) для asyncio и inspect:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 coroutines.py:160(iscoroutinefunction)
        1    0.000    0.000    0.000    0.000 inspect.py:158(isfunction)
        1    0.000    0.000    0.000    0.000 inspect.py:179(iscoroutinefunction)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.isinstance}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 inspect.py:158(isfunction)
        1    0.000    0.000    0.000    0.000 inspect.py:179(iscoroutinefunction)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.isinstance}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


Полный код:
from inspect import iscoroutinefunction

from asyncio import QueueEmpty, QueueFull
from concurrent.futures import TimeoutError


class ProcessException(object):

    __slots__ = ('func', 'handlers')

    def __init__(self, custom_handlers=None):
        self.func = None

        if isinstance(custom_handlers, property):
            custom_handlers = custom_handlers.__get__(self, self.__class__)

        def raise_exception(e: Exception):
            raise e

        exclude = {
            QueueEmpty: lambda e: None,
            QueueFull: lambda e: None,
            TimeoutError: lambda e: None
        }

        self.handlers = {
            **exclude,
            **(custom_handlers or {}),
            Exception: raise_exception
        }

    def __call__(self, func):
        self.func = func

        if iscoroutinefunction(self.func):
            def wrapper(*args, **kwargs):
                return self._coroutine_exception_handler(*args, **kwargs)
        else:
            def wrapper(*args, **kwargs):
                return self._sync_exception_handler(*args, **kwargs)

        return wrapper

    async def _coroutine_exception_handler(self, *args, **kwargs):
        try:
            return await self.func(*args, **kwargs)
        except Exception as e:
            return self.handlers.get(e.__class__, Exception)(e)

    def _sync_exception_handler(self, *args, **kwargs):
        try:
            return self.func(*args, **kwargs)
        except Exception as e:
            return self.handlers.get(e.__class__, Exception)(e)



И наверное, пример был бы неполным без timeit. Поэтому используя пример из вышеупомянутого комментария:
class MathWithTry(object):
    def divide(self, a, b):
        try:
            return a // b
        except ZeroDivisionError:
            return 'Делить на ноль нельзя, но можно умножить'


и пример из текста предыдущей статьи (ВНИМАНИЕ! в пример из текста в лямбду мы передаем e. В предыдущей статье этого не было и добавилось только в нововведениях):
class Math(object):
    @property
    def exception_handlers(self):
        return {
            ZeroDivisionError: lambda e: 'Делить на ноль нельзя, но можно умножить'
        }
    
    @ProcessException(exception_handlers)
    def divide(self, a, b):
        return a // b


вот вам результаты:
timeit.timeit('math_with_try.divide(1, 0)', number=100000, setup='from __main__ import math_with_try')
0.05079065300014918

timeit.timeit('math_with_decorator.divide(1, 0)', number=100000, setup='from __main__ import math_with_decorator')
0.16211646200099494


В качестве заключения хочу сказать, что оптимизация, на мой взгляд, процесс достаточно непростой и тут важно не увлечься и не оптимизировать что-то в ущерб читаемости. Иначе дебажить по оптимизированному будет крайне сложно.

Благодарю за ваши комментарии. Жду комментариев и к этой статье тоже :)

P.S. благодаря замечаниям пользователей хабра удалось еще больше ускорить, вот, что получилось:
from inspect import iscoroutinefunction

from asyncio import QueueEmpty, QueueFull
from concurrent.futures import TimeoutError


class ProcessException(object):

    __slots__ = ('handlers',)

    def __init__(self, custom_handlers=None):
        if isinstance(custom_handlers, property):
            custom_handlers = custom_handlers.__get__(self, self.__class__)

        raise_exception = ProcessException.raise_exception

        exclude = {
            QueueEmpty: lambda e: None,
            QueueFull: lambda e: None,
            TimeoutError: lambda e: None
        }

        self.handlers = {
            **exclude,
            **(custom_handlers or {}),
            Exception: raise_exception
        }

    def __call__(self, func):
        handlers = self.handlers

        if iscoroutinefunction(func):
            async def wrapper(*args, **kwargs):
                try:
                    return await func(*args, **kwargs)
                except Exception as e:
                    return handlers.get(e.__class__, handlers[Exception])(e)
        else:
            def wrapper(*args, **kwargs):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    return handlers.get(e.__class__, handlers[Exception])(e)

        return wrapper

    @staticmethod
    def raise_exception(e: Exception):
        raise e



timeit.timeit('divide(1, 0)', number=100000, setup='from __main__ import divide')
0.13714907199755544


Ускорилось на 0.03 в среднем. Спасибо Kostiantyn и Yngvie.

P.S. Обновлено! Я еще больше оптимизировал код, на основе замечаний из комментариев onegreyonewhite и resetme. Заменил self.func на просто func и self.handlers вынес в переменную. Выполнение дополнительно ускорилось, особенно заметно, если повторов на нолик больше. Привожу timeit:
timeit.timeit('t.divide_with_decorator(1, 0)', number=1000000, setup='from __main__ import t')
1.1116105649998644


До этой оптимизации выполнение с таким же значением number занимало 1.24 в среднем.

P.S. я еще больше оптимизировал, вынеся в @staticmethod функцию raise_exception из __init__ и к ней обращаюсь через переменную, чтобы убрать обращение через точку. Собственно, среднее время выполнения стало:
timeit.timeit('t.divide_with_decorator(1, 0)', number=1000000, setup='from __main__ import t')
1.0691639049982768


это для метода. А функции вызываются еще быстрее (в среднем):
timeit.timeit('div(1, 0)', number=1000000, setup='from __main__ import div')
1.0463485610016505
Теги:
Хабы:
+9
Комментарии 27
Комментарии Комментарии 27

Публикации

Истории

Работа

Data Scientist
66 вакансий
Python разработчик
136 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн