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

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

    Для начала хочу поблагодарить 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
    
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 27

      0

      Есть подозрение, что эта строчка делает не то, что от нее ожидают.


      self.handlers.get(e.__class__, Exception)(e)

      Второй параметр — это значение по умолчанию, а не ключ по умолчанию. Вместо вызова raise_exception просто вернётся Exception

        0
        благодарю за поправку! внесу в статью (и в свой код)
        0
        Еще можно слегка ускорить используя partial:
        from functools import partial
        ...
            def __call__(self, func):
                self.func = func
        
                if iscoroutinefunction(self.func):
                    return partial(self._coroutine_exception_handler, self)
                return partial(self._sync_exception_handler, self)
        ...
        
          0
          возможно, я что-то делаю не так, но у меня timeit показывает одинаковое время для моего и вашего варианта. А еще ваше решение справедливо только для методов, но не для функций.
            0
            Еще нолик добавьте к количеству лупов. В примере маловато для быстрых машин. Еще может быть Python не собран с С модулем functools, а в коде есть файлбэк на питоновскую версию. Пропробуйте симортировать модуль _functools. Если импортируется, то все ОК.

            В новых питонах еще есть partialmethod, но идея такая же.
              0
              _functools импортируется. Но ваш пример не работает для функций, потому что там self нету
                0
                А метод разве не функция которой передается self первым параметром?
                  +1
                  да, метод — это функция, принадлежащая классу. Поэтому и разделяют понятия функция и метод. Для функций ваш код не работает, потому что вы пытаетесь в них тоже передавать self. Таким образом, передается на аргумент больше и рейзится TypeError.
                0
                а, кстати, почему partial ускоряет выполнение? я попытался разобраться, только ни к чему не пришел пока что.
                  0
                  self как бы вмораживается в вызов и на него не таратится время на его передачу в стек, а вся машинерия на С, от суда есть прирост но незначительный. А лучше вообще не вызывать, как в соседней ветку.

                  Странно вы все же измеряете. Вот так у меня:
                  $ python3 -m timeit -s 'from test import math_with_try' 'math_with_try.divide(1,0)'
                  1000000 loops, best of 3: 0.415 usec per loop
                  $ python3 -m timeit -s 'from test import math_with_decorator' 'math_with_decorator.divide(1,0)'
                  1000000 loops, best of 3: 1.08 usec per loop
                  $ python3 -m timeit -s 'from test import math_with_partial' 'math_with_partial.divide(1,0)'
                  1000000 loops, best of 3: 0.998 usec per loop
                  
                    0
                    благодарю за объяснение, partial — полезно учесть для работы с методами. Но если я правильно понял, то для функций эта оптимизация не имеет значения?
                      0
                      Небольшой прирост будет:
                      $ python3 -m timeit -s 'from test1 import divide_decorator' 'divide_decorator(1,0)'                       
                      1000000 loops, best of 3: 1.07 usec per loop
                      $ python3 -m timeit -s 'from test1 import divide_partial' 'divide_partial(1,0)'                           
                      1000000 loops, best of 3: 0.993 usec per loop

                      Только вызов как-то вот так нужно сделать:
                          def __call__(self, func):
                              if iscoroutinefunction(func):
                                  return partial(self._coroutine_exception_handler, func)
                              return partial(self._sync_exception_handler, func)
                      
                          def _sync_exception_handler(self, func, *args, **kwargs):
                              try:
                                  return func(*args, **kwargs)
                              except Exception as e:
                                  return self.handlers.get(e.__class__, Exception)(e)
                      

                      Получается так чем больше аргументов в partial передаешь, там больше выигрыш.
                        0
                        спасибо за пример. На текущий момент код был слегка отредактирован и оптимизирован, я в конце статьи добавил обновленный пример и учел кое-что из ваших замечаний. Возможно, в коде выше тоже нет предела совершенству?
                          0
                          Вы все же некорректно измеряете. Вам функция возвращает время выполнения цикла 100000 итераций. Во время выполнения на машине могут произойти прерывания и интрепретатор Python может получить меньший квант времени, а это скажется на времени исполнения этого цикла.

                          Если запускать timeit из командной строки, то выводится лучшее время из 3 запусков цикла. Это позволяет нивелировать прерывания во время исполнения, так как лучшее время исполнения даст та итерация во время которой произойдет наименьшее количество прерываний.

                          Обпатите внимание на это предложение в документации docs.python.org/3/library/timeit.html
                          Note however that timeit() will automatically determine the number of repetitions only when the command-line interface is used.

                          Другая проблема в самом декораторе. По сути он замалчивает исключения по таймауту и исключения от асинхронной очереди. Что в корне неправильно. Они должны быть обработаны и желательно не в декораторе, а в коде метода или функции. Иначе ваш декоратор может привести к ситуации когда замолченное исключение будет годами прятать баг. Лучше не замалчивать, а дать упасть приложению, а по трейсбеку найти проблему и пофиксить.

                          Сейчас в мире Python получилось так что мы имеем два Python: один асинхронный, а другой синхронный. Они в принципе разные и лучше код для разных питонов держать отдельно. Иначе выходят вот такие библиотеки, которые позволяют любым путем, но выполнить функцию будь она асинхронная или синхронная: github.com/miyakogi/syncer/blob/master/syncer.py

                          Да и просто расставив sync/await не получить правильно работающий асинхронный код. Если убрать асинхронность из декоратора, то вообщем ничего не изменится для вызываемого под ним метода или функции. По существу обработка исключений — это не IO bound задача для которой придумали асинхронность, поэтому нет смысла обрабатывать исключения асинхронно, так как в момент обработки может прилететь какое-то совсем другое исключение и совсем из другого места кода.

                          Для обучения это декоратор хороший пример и мне понравился Ваш подход к постоянному улучшению. Да и обе статьи получились интересные.

                            0
                            Другая проблема в самом декораторе. По сути он замалчивает исключения по таймауту и исключения от асинхронной очереди.


                            не, тут все правильно. Это специфика моего проекта. Там сервер в инфинити лупе ожидает запросов с клиента и отвечает. А если ничего не пришло, возникает TimeoutError. Есть ряд исключений, которые мне приходится игнорировать, потому что они происходят в цикле. Постоянно. Но это конкретно мой проект. А в общем, конечно, лучше убрать.

                            P.S. благодарю за положительную оценку статей
            +1

            У Вас ещё wrapper дополнительно вызывает другой метод — что в Python весьма не бесплатно.
            Т. е. содержимое методов _coroutine_exception_handler и _sync_exception_handler по хорошему бы переместить внутрь объявления самих wrapper функций.

              0
              обожаю хабр :) да, это очень важный момент, выполняться стало аж на 0.03 быстрее, добавлю в статью (и в свой код). Благодарю за замечание
              –1
              ИМХО Высокопроизводительные задачи лучше перекладывать на C(++) или другие производительные языки(Rust, Go(в некоторых задачах)).
              В python что что а оптимизации кот наплакал и результат чаще всего не превышает пары десятков процентов, та и в целом язык отстаёт по скорости от С-шных собратьев.
              Для задач автоматизации по типу парсинга и обработки данных он отлично подходит(scrapy, bs, lxml). Читаемость кода, скорость и асинхронность здесь на хорошем уровне. В случае если нужна производительность лично предпочёл бы C.
                0

                Здесь явно не то что вы хотите:


                custom_handlers = custom_handlers.__get__(self, self.__class__)

                Нужно использовать дескрипторы чтобы получить правильный self

                  0

                  Пример (парсер ест двойные пустые строки):


                  import types
                  from asyncio import QueueEmpty, QueueFull
                  from concurrent.futures import TimeoutError
                  from functools import wraps
                  from inspect import iscoroutinefunction
                  
                  class ProcessException:
                      __slots__ = ('func', 'custom_handlers')
                  
                      exclude = {
                          QueueEmpty: lambda e: None,
                          QueueFull: lambda e: None,
                          TimeoutError: lambda e: None
                      }
                  
                      def __init__(self, custom_handlers=None):
                          self.func = None
                          self.custom_handlers = custom_handlers
                  
                      def __call__(self, func):
                          self.func = func
                  
                          if isinstance(self.custom_handlers, property):
                              return self
                  
                          return self._get_wrapper()
                  
                      def __get__(self, instance, owner):
                          setattr(owner, self.func.__name__, self._get_wrapper(instance, owner))
                  
                          return getattr(instance, self.func.__name__)  # return bounded method
                  
                      def _get_wrapper(self, instance=None, owner=None):
                          if isinstance(self.custom_handlers, property):
                              self.custom_handlers = self.custom_handlers.__get__(instance, owner)
                  
                          handlers = {
                              **self.exclude,
                              **(self.custom_handlers or {}),
                              Exception: self._raise_exception
                          }
                          del self.custom_handlers
                  
                          if iscoroutinefunction(self.func):
                              async def wrapper(*args, **kwargs):
                                  try:
                                      return await self.func(*args, **kwargs)
                                  except Exception as e:
                                      return handlers.get(type(e), handlers[Exception])(e)
                  
                          else:
                              def wrapper(*args, **kwargs):
                                  try:
                                      return self.func(*args, **kwargs)
                                  except Exception as e:
                                      return handlers.get(type(e), handlers[Exception])(e)
                  
                          return wraps(self.func)(wrapper)
                  
                      @staticmethod
                      def _raise_exception(e: Exception):
                          raise e
                  
                  class Math(object):
                      def __init__(self, error_message: str = 'Cannot divide by zero!'):
                          self.error_message = error_message
                  
                      @property
                      def exception_handlers(self):
                          return {
                              ZeroDivisionError: lambda e: self.error_message
                          }
                  
                      @ProcessException(exception_handlers)
                      def divide(self, a, b):
                          return a // b
                  
                  @ProcessException({ZeroDivisionError: lambda e: 'Cannot divide by zero!'})
                  def divide(a, b):
                      return a // b
                  
                  assert Math().divide(4, 2) == 2
                  assert divide(4, 2) == 2
                  assert Math().divide(4, 0) == 'Cannot divide by zero!'
                  assert divide(4, 0) == 'Cannot divide by zero!'
                  assert Math().divide.__name__ == 'divide'
                  

                  В принципе всю эту магию можно было и в во врапер запихнуть.

                    0
                    я пытаюсь получить значение property, оно вернется даже если я передам object.__class__:
                    custom_handlers = custom_handlers.__get__(self, object.__class__)
                    


                    если вы считаете, что такое поведение неправильное и может привести к багу — можете ли вы привести шаги воспроизведения этого бага?
                      0

                      Проблема будет при обращении к self внутри проперти:


                      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
                      
                      class Math(object):
                          def __init__(self, error_message: str = 'Cannot divide by zero!'):
                              self.error_message = error_message
                      
                          @property
                          def exception_handlers(self):
                              return {
                                  ZeroDivisionError: lambda e: self.error_message
                              }
                      
                          @ProcessException(exception_handlers)
                          def divide(self, a, b):
                              return a // b
                      
                      assert Math().divide(4, 2) == 2
                      assert Math().divide(4, 0) == 'Cannot divide by zero!'
                      assert Math().divide.__name__ == 'divide'
                      

                      AttributeError: 'ProcessException' object has no attribute 'error_message'
                  0
                  class ProcessException:
                       ...
                       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
                  

                  В этом месте вообще не вижу смысла в том, чтобы каждый раз возвращать wrapper и ещё и создавать его. Почему бы сразу не возвращать метод объекта?
                  Более того, каждый раз когда вызывается self., то Python ищет этот атрибут в словаре атрибутов. Если возвращать сразу метод, то это минус один сложный вызов (внутри wrapper'а).
                  Либо, как предложил ADR, логику запихнуть во wrapper, но создавать по методу на каждую декорацию мне кажется расточительным по памяти и немного нагромождённым для одного метода.

                    0
                    мы не можем сразу возвращать метод объекта, потому что метод может быть с async, а значит, придется его дождаться (await), а уже потом обернуть в try except. Но чтобы дождаться, функция-контейнер (в нашем случае, __call__) тоже должна быть async. Со всеми отсюда вытекающими. Мне не нужен async __call__.
                      0

                      Во-первых, wrapper тоже не async, а значит "мне не нужен async call" — решаемая проблема.
                      Во-вторых, оберните только async: для чего оборачивать обычный метод?

                        +1
                        для того, чтобы вернуть обработчик исключения (если таковой есть). В секции except тоже происходит вызов функции и в сценарии с wrapper-ом она вполне вписывается. Не очень представляю, как можно выполнить обработчик, если мы не будем оборачивать во wrapper. Я, кстати, на основе комментариев выше кое-что еще оптимизировал. Поубирал self и получилось еще быстрее — особенно заметно, когда лупов на нолик больше, чем в примере выше. Т.е. ранее было 1.24, стало — 1.08 секунды для 1000000 повторов. Сейчас добавлю в статью обновленный код.
                    +1
                    Я еще больше оптимизировал. Вынес в @staticmethod из __init__ функцию raise_exception и через переменную уже к ней обращаюсь (чтобы убрать вызов через точку). В итоге среднее выполнение для 1000000 повторов стало 1.06. Сейчас отредактирую пример кода выше.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое