Производительность в Python. Легкий путь

http://jiaaro.com/python-performance-the-easyish-way
  • Перевод
  • Tutorial
Всегда знал, что одно из достоинств Python — возможность переписать самые тормозные куски кода на Си и увеличить быстродействие программы до недостижимых интерпретируемым языкам высот. Но сам ни разу не пробовал, т.к. считал что это слишком сложно. После прочтения этой статьи больше так не считаю.

Программисты знакомые с ctypes врядли найдут тут что-то интересное, новичков же прошу под кат.

Ctypes — механизм Python для импорта функций из внешних библиотек.
%timeit — magic-функция оболочки IPython, измеряющая время выполнения выражения на Python


Ctypes — это прекрасно! Давайте начнем с небольшого банального примера: суммирование чисел в определенном диапазоне.
Вот реализация этой функции на Python
def sumrange(arg):
    return sum(xrange(arg))

Отлично! Но что если мы попробуем суммировать действительно большой диапазон чисел, например от 0 до 10**8 (т.е. 100,000,000)
In [2]: %timeit sumrange(10**2)
1000000 loops, best of 3: 1.53 us per loop

In [3]: %timeit sumrange(10**8)
1 loops, best of 3: 9.77 s per loop

In [4]: %timeit sumrange(10**9)
1 loops, best of 3: 97.8 s per loop

Уже не так весело. Попробуем кое-что другое:
def sumrange2(arg):
    x = i = 0
    while i < arg:
        x += i
        i += 1
    return x

Что из этого получится?
In [10]: %timeit sumrange2(10**2)
100000 loops, best of 3: 10.5 us per loop

In [11]: %timeit sumrange2(10**8)
1 loops, best of 3: 18.5 s per loop

Вот это да… Так еще хуже… В этот раз даже не буду пробовать 10**9.
Так как же нам ускорить выполнение? Только не предлагайте математические оптимизации… мы же в новом мире компьютеров! (в оригинале: don't suggest math tricks… this is the the new world of computing!)
Да, я знаю, что сложность алгоритма — постоянная величина и не зависит о величины аргумента, n*(n+1)/2. Но статья посвящена не этому.

Как насчет ctypes?

#include <stdio.h>

unsigned long long sumrange(unsigned long long arg)
{
    unsigned long long i, x;
    x = 0;

    for (i = 0; i < arg; i++) {
        x = x + i;
    }
    return x;
}

Сохраним с именем sumrange.c и скомпилируем (не будем использовать оптимизации для чистоты эксперимента):
$ gcc -shared -Wl,-install_name,sumrange.so -o sumrange.so -fPIC sumrange.c

Импортируем в Python то что получилось:
import ctypes
sumrange_ctypes = ctypes.CDLL('./sumrange.so').sumrange
sumrange_ctypes.restype = ctypes.c_ulonglong
sumrange_ctypes.argtypes = ctypes.c_ulonglong,


И Оскар получает…

In [15]: %timeit sumrange_ctypes(10**2)
1000000 loops, best of 3: 1.28 us per loop

In [16]: %timeit sumrange_ctypes(10**8)
1 loops, best of 3: 381 ms per loop

In [17]: %timeit sumrange_ctypes(10**9)
1 loops, best of 3: 3.79 s per loop

In [18]: %timeit sumrange_ctypes(10**10)
1 loops, best of 3: 37.8 s per loop


Итоговая сводка:
10**2 10**8 10**9 10**10
Чистый Python, способ №1 1.53 мкс 9.77 с 97.8 с -
Чистый Python, способ №2 10.5 мкс 18.5 с - -
ctypes 1.28 мкс 381 мс 3.79 с 37.8 с


Адский прирост производительности!

Для Node.js хакеров, есть эквивалент ctypes — FFI (Foreign Function Interface): github.com/rbranson/node-ffi
Метки:
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 41
  • +4
    Стоило бы еще упоминуть про cython: для арифметики он нормально заменяет ctypes и при этом более дружелюбный для тех кто не знает или не любит C. Кроме того он еще и более менее нормальные классы понятные для питона позволяет писать.

    немного найденого кода из pygame PaintBrush
    cimport cython
    import operator
    import math
    
    cdef extern from "math.h":
        double sqrt(double x)
        double atan2(double y, double x)
        double cos(double x)
        double sin(double x)
        double floor(double x)
    
    cdef class v2d(object):
        cdef public double x, y
        def __init__(self, x, y=None):
            if x is not None and y is not None:
                self.x = float(x)
                self.y = float(y)
            elif type(x) == v2d:
                self.x = x.x
                self.y = x.y
            elif type(x) == list or type(x) == tuple:
                self.x = x[0]
                self.y = x[1]
            else:
                raise TypeError()
        
        # Position
        def get_pos(self):
            return (self.x, self.y)
        def set_pos(self,pos):
            self.x = float(pos.x)
            self.y = float(pos.y)
        pos = property(get_pos, set_pos)
    
        def get_int_pos(self):
            cdef int x, y
            x = int(floor(self.x))
            y = int(floor(self.y))
            return(x,y)
        ipos = property(get_int_pos, set_pos)
    
    # ...
    


    А в некоторых случаях можно вообще писать чистый питон код и он его «ускорит».
    • 0
      >>А в некоторых случаях можно вообще писать чистый питон код и он его «ускорит».
      Пример пожалуйста, а то юзаю как плюсы так и питон. Но навскидку не могу привести пример Ваших «ускорит», а очень интересно!
      • +1
        Ну удачный случай я подобрать не смог, а все свои старые эксперименты я потерял.

        Вот файлы для проверки:
        gist.github.com/4030438

        imp@home ~/zz/cython $ python pyt.py 
        12.2809770107
        imp@home ~/zzz/cython $ python cyt.py
        10.8371829987
        


        Отрыв хоть и небольшой, но сохраняется постоянно. У них там есть какая-то магия с numpy — вроде всё что использует его может ускорится.

        Ну а в целом всё-равно нужно прибегать к дополнительной магии: если добавить еще один файл который подскажет cython`у где какие типы переменных, то отрыв сразу увеличится, а модуль при этом всё-равно можно будет импортировать как скомпилированный, так и не скомпилированный,

        Например в файл v2d.pxd добавим такое:
        cimport cython
        
        cdef class v2d(object):
            cdef public float __x, __y
        


        и запустим заново:
        imp@home ~/zzz/cython $ python cyt.py
        8.40173888206
        ...
        8.0
        


        И так можно дальше улучшать, например как в предыдущем комментарии взять функции из math.h и использовать их. Еще можно взять и переопределить функции:
        cimport cython
        
        cdef class v2d(object):
            cdef public float __x, __y
        
            cpdef float get_length(self)
        

        В данном случае это прироста в скорости не дало, может быть даже замедлило

        Еще можно обойтись и без создания pxd и писать прямо в .py файле сохраняя совместимость (см v2d_pure.py)
        В данном случае вс' стало даже медленнее.

        Реальные ускорения можно получить если поступать так:
        Делать большинство методов чисто сишнымии во внтутренних рассчётах использовать из, а наружу выставлять уже функции питона

        pyfile:
        @cython.cfunc
        @cython.returns(cython.float)
        @cython.locals(x=cython.float, y=cython.float)
        def __cget_length(self, x, y):
            pass
        
        @cython.cfunc
        @cython.returns(cython.float)
        @cython.locals(x=cython.float, y=cython.float, z=cython.float)
        def __ccomplex_calcs(self, x, y):
            z = self.__cget_length(x, y) ** 8 * self.__cget_anima()
        
        def get_length(self, x, y):
            return self.__cget_length(x, y)
        
        def complex_calcs(self, x,y):
            return self.__ccomplex_calcs( x, y)
        


        Мне этот «Pure» режим что-то выглядит стрёмно — pxd файл как-то попроще.

        *.pxd: +simpler, -harder to sync
        @cython.decorators: -huge, +always up to date

        Ну а вообще если хочется из него выжать всёи получить прирост в скорости десятки и сотни раз, то надо использовать сишные функции как в комментарии выше, писать сразу pyx файл, а не разбивать на py and pxd. В чем еще преимущество cython, так это в том, что на выходе можно получить библиотеку v2d.so и использовать её хоть прямиком из Си, так-что те кто си не любит может всё-равно выдавать для него либы
        • +1
          На прошлой работе использовали cython для обфускации кода.
          Кое-где делали жесткую обфускацию, а коли это почти бесплатно все остальные «core.py» в каждом модуле тоже тупо переименовывали в pyx и компилили.

          Факт фактом — почти любой питонячий код простая перекомпилляция в cython ускоряет)

          Не знаю как дело обстоит сейчас с python 3.3 :)
      • 0
        Питон берет скоростью разработки. Когда нужна производительность, пайтон — далеко не лучший вариант. Иногда получаешь граблями по лбу даже там, где совсем не ожидаешь.

        Вот к примеру, две одинаковые реализации флойда, тут и тут. Разницы вроде как и нет, а разница во времени выполнения ощутимая.
        • 0
          Это известный факт: глобальные переменные работают медленнее локальных.
          • –1
            Объясните новичку, почему так получается?
            • +7
              Для доступа к глобальной переменной используется поиск по hashtable (dict), локальные доступны просто по целочисленному индексу. Поиск по hashtable, понятное дело, гораздо дороже.
        • +7
          … и Оскар переходит к cython!

          Вот продолжение простенького бенчмарка. Код расположен в gist — https://gist.github.com/4025567. Запускаем!

          $ for power in 2 4 6 ; do for module in timing_{native,ctypes,cython,cython_range} ; do echo -n "$module: $power "; python2 -m timeit --setup "from $module import test" "test(10 ** $power)" ; done ; echo "---------" ; done
          timing_native: 2 100000 loops, best of 3: 4.72 usec per loop
          timing_ctypes: 2 100000 loops, best of 3: 2.27 usec per loop
          timing_cython: 2 1000000 loops, best of 3: 0.21 usec per loop
          timing_cython_range: 2 1000000 loops, best of 3: 0.211 usec per loop
          ---------
          timing_native: 4 1000 loops, best of 3: 448 usec per loop
          timing_ctypes: 4 10000 loops, best of 3: 30.1 usec per loop
          timing_cython: 4 100000 loops, best of 3: 5.57 usec per loop
          timing_cython_range: 4 100000 loops, best of 3: 6 usec per loop
          ---------
          timing_native: 6 10 loops, best of 3: 248 msec per loop
          timing_ctypes: 6 100 loops, best of 3: 2.59 msec per loop
          timing_cython: 6 1000 loops, best of 3: 541 usec per loop
          timing_cython_range: 6 1000 loops, best of 3: 586 usec per loop
          ---------
          


          Отмечу, что можно использовать даже родные циклы через range (если переменная-итератор — int, то он будет развернут в родной сишный).

          Не владею магией низкоуровнего питона, предполагаю, что у cython меньше оверхед на преобразование типов в сишные и обратно при вызове.
          • 0
            Ради интереса сравнивал скорость работы Delphi-проекта и python. В теле цикла сделал выражение со сложением, умножением и делением. Плюс периодичный вывод значений (раз в 10000 вроде бы, уже не помню). Результат тоже поразил: примерно в 100 раз делфи был быстрее. Далее написал библиотеку на CPython (функция-цикл вызывала переданную из python-кода функцию для вывода промежуточных результатов) и откомпилировал ее, подгрузил в Python-программу. В итоге ускорил цикл в 20 раз на windows и примерно в 15 на linux (почему такая разница получилась — не разбирался).
            • +1
              на живой программе с IO результаты сравнения будут совсем другими.
              А если просто «считать числа», то GPU (с тем же pyopencl) порвет всех как Тузик тряпку.
            • 0
              Я полагаю разница из-за того, что при компиляции ctypes не были включены оптимизации, а при компиляции cython было включено -O2.
              • +1
                ctypes очень медленный на переходе барьера C <-> Python, если что
                • +1
                  Ну чтобы это продемонстрировать, нужно сравнить программы откомпилированные с одинаковыми ключами, верно?
                  • 0
                    Верно для рассматриваемого примера.
                    Для оценки же тормозов вызова функций через ctypes нужно тестировать вызов пустой функции.
                    Тормоза — одна из главных причин почему в CPython stdlibrary не используется ctypes.
                    Вторая — ABI гораздо более изменчив чем API и поддерживать ABI совместимость заметно труднее.
                    Недавно эта тема опять поднималась на python-dev: code.activestate.com/lists/python-dev/118511/
            • +1
              Ну и для тех, кто не хочет сильно думать о типах и вызове сей из питона, я упомяну про такую штуку как SWIG, который умеет генерировать обертки.
              • НЛО прилетело и опубликовало эту надпись здесь
                • 0
                  Спасибо, это мне не попадалось. Хотя, справедливости ради, скажу, что у меня после некоторых плясок с бубном (а где их нет?) SWIG тихо мирно делает то, что я от него хочу без каких либо проблем.
                  • НЛО прилетело и опубликовало эту надпись здесь
                    • 0
                      Ага, именно с плюсами он у меня и используется.
                      • +1
                        Для плюсов больший смысл имеет boost.python
                        • 0
                          к слову
                          сэмпл используя boost::python (windows, boost 1.51, release)

                          #include <boost/python.hpp>
                          using namespace boost::python;
                          
                          namespace Test
                          { 
                               unsigned long long sumrange(unsigned long long arg)
                               {
                                    unsigned long long i, x;
                                    x = 0;
                                    for (i = 0; i < arg; i++) {
                                         x = x + i;
                                    }
                                    return x;
                               }
                          }
                          
                          
                          BOOST_PYTHON_MODULE(boost_python_module)
                          {
                               def("sumrange", Test::sumrange);
                          }
                          

                          import timeit
                          from boost_python_module import sumrange as sumrange2
                          
                          def sumrange1(arg):
                              return sum(xrange(arg))
                          
                          for power in (2, 8, 9):
                              loops = 10**power
                              t = timeit.Timer(lambda : sumrange1(loops))
                              print("Loops: {0} Time: {1}".format(loops, t.timeit(1)))
                          print()
                          for power in (2, 8, 9):
                              loops = 10**power
                              t = timeit.Timer(lambda : sumrange2(loops))
                              print("Loops: {0} Time: {1}".format(loops, t.timeit(1)))
                          

                          Результат:
                          Loops: 100 Time: 8.08813168841e-06
                          Loops: 100000000 Time: 14.5755965742
                          Loops: 1000000000 Time: 143.893784101
                          ()
                          Loops: 100 Time: 2.51157773619e-05
                          Loops: 100000000 Time: 0.173007690962
                          Loops: 1000000000 Time: 1.69442229668
              • 0
                Не понятно только, в чём смысл не использовать оптимизацию. Ещё за компанию OpenMP / GPU /…
                • 0
                  В оригинальной статье автор сперва использует оптимизацию, но в этом случае компилятор оптимизирует алгоритм так, что время выполнения становится постоянным независимо от аргумента. Я эту часть статьи просто пропустил
                  Иначе говоря, оптимизации отключена, чтоб сравнивать реализацию одного и того же алгоритма.
                  • 0
                    Думаю, что автор оригинальной статьи схалтурил. Какой смысл бороться за производительность, и при этом выключать оптимизацию? Можно было сделать сумму немного сложнее, чтобы компилятор не суммировал арифметическую последовательность.
                    • 0
                      Просто автор оригинальной статьи подобрал не самый лучший синтетический пример.
                      А автор перевода хотел заменить его чем-нибудь поудачнне, например поиском N-го простого числа, но постеснялся, т.к. это уже не было бы переводом, а писать авторские статьи, на темы в которых плохо разбираешься не позволяет совесть.
                • +3
                  у меня сломался CTRL-F. Я не смог найти pypy
                  • 0
                    Это к чему?
                    Сбываются мои худшие подозрения: все участники обсуждения, включая переводчика статьи, не имеют опыта использвания обсуждаемых инструментов в реальной работе?
                    Или вы таки используете PyPy в production?
                    • 0
                      не знаю на какой вопрос отвечать сначала, и не знаю тянут ли на «реальную работу» мои последние 3 года, где 90% кода работает ежедневно под pypy.
                      • 0
                        Если вы последние три года используете PyPy на работе повсеместно — извиняюсь и снимаю шляпу.
                        А что с оставшимися 10%? Какую версию pypy используете сейчас? С какой начали? Какие были неприятности и неожиданности? Баги?
                        • 0
                          > А что с оставшимися 10%?

                          1. пара модулей, где pypy странно требует больше памяти, чем CPython (ведь обычно даже меньше), но возможно дело в этом: bugs.pypy.org/issue1282

                          2. lxml падал в многопоточных приложениях (трудно воспроизводимо)

                          это из последних лет, а это из грядущего:

                          3. pyCUDA/Theano не собирается даже

                          > Какую версию pypy используете сейчас?

                          сборку двухнедельной давности из репозитория, где fijal допили последний принципиальный для меня issue по numpy

                          > С какой начали?

                          следил с 0.9х какой-то, а пользоваться, где-то между 1.2 и 1.3

                          Неприятности были скорее лишь в несовместности с некими важными библиотеками использующих внешний С-код (в первую очередь numpy, lxml), но это уже уходит в прошлое.

                          Багов как таковых не было: трансляция pypy из pypy — это очень неслабый тест, который стоит в основе каждой сборки.
                          • 0
                            Если не секрет, расскажите, чем занимаетесь?
                            • 0
                              краулинг + text-mining движок

                              пока с одним приложением (для онлайн рекрутингового рынка), потом есть конкретные планы к другим областям применять.
                    • 0
                      Недавно попробовал на одном из алгоритмов pypy и был в детском восторге от производительности. Огорчило, что с numpy не удалось подружить – это печаль, конечно.
                      • 0
                        > Огорчило, что с numpy не удалось подружить – это печаль, конечно

                        что конкретно не вышло?
                        • 0
                          Вспомнить бы)
                          Так как numpy использовался мало – спокойно его выпилил.
                          Может быть я уже подзабыл и на самом деле из сторонних библиотек у меня PyYAML не завёлся. Но сейчас уже, к сожалению, не вспомнить.
                    • +3
                      Откуда эти значение по оси x 10^2, 10^8, 10^9 вы собираетесь график на логарифмической шкале рисовать?
                      97.8 с /10^9 = 97 наносекунд на одно число.
                      3.79 с /10^9 = 4 наносекунды.

                      Я бы не стал называть эти результаты катастрофой, потому что это интерпретируемый язык. Операции в тесте настолько просты, что они могут трансформироваться в простые asm инструкции и я не исключаю, что можно достичь даже 1 наносекунды. Я бы посмотрел на это даже с другой стороны, стандартный overhead операции 90 наносекунд, что совсем немного. Если вы не декодируете видео, то для сортировки 100 строк и вывода их на экран 1 000 000 операций в секунду вполне достаточно.
                      • 0
                        Use Numba, nigga: numba.pydata.org/
                        • НЛО прилетело и опубликовало эту надпись здесь
                          • НЛО прилетело и опубликовало эту надпись здесь
                            • 0
                              Если использовать boost::python, то сразу получите готовый модуль Python.
                              Всё что останется — сделать import.

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