Рубрики
Без рубрики

Оптимизация производительности Python

Мы будем оптимизировать общие шаблоны и процедуры в программировании на Python, чтобы повысить производительность и улучшить использование доступных вычислительных ресурсов.

Автор оригинала: Robley Gori.

Оптимизация производительности Python

Вступление

Ресурсов никогда не бывает достаточно для удовлетворения растущих потребностей в большинстве отраслей, а сейчас особенно в технологиях, поскольку они все глубже проникают в нашу жизнь. Технология делает жизнь проще и удобнее, и она способна развиваться и становиться лучше с течением времени.

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

Требования к производительности приложений растут больше, чем может выдержать наше оборудование. Чтобы бороться с этим, люди придумали множество стратегий более эффективного использования ресурсов – Контейнеризация , Реактивные (асинхронные) приложения и т. Д.

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

В этой статье мы оптимизируем общие шаблоны и процедуры в программировании на Python, чтобы повысить производительность и улучшить использование доступных вычислительных ресурсов.

Проблема с производительностью

По мере масштабирования программных решений производительность становится все более важной, а проблемы-все более масштабными и заметными. Когда мы пишем код на нашем localhost , легко пропустить некоторые проблемы с производительностью, так как использование не является интенсивным. Как только одно и то же программное обеспечение развертывается для тысяч и сотен тысяч одновременных конечных пользователей, проблемы становятся более сложными.

Медлительность-одна из главных проблем, которая возникает при масштабировании программного обеспечения. Это характеризуется увеличением времени отклика. Например, веб-серверу может потребоваться больше времени для обслуживания веб-страниц или отправки ответов клиентам, когда запросов становится слишком много. Никто не любит медленную систему, тем более что технология предназначена для ускорения определенных операций, а удобство использования снизится, если система будет медленной.

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

Несогласованность и ошибочный вывод-еще один результат плохо оптимизированных программ. Эти моменты подчеркивают необходимость оптимизации программ.

Почему и когда нужно оптимизировать

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

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

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

Но когда мы оптимизируем?

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

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

Профилирование

Прежде чем мы сможем оптимизировать ваш код, он должен работать. Таким образом, мы можем сказать, как он работает и использует ресурсы. И это подводит нас к первому правилу оптимизации – Не .

Как выразился Дональд Кнут – математик, компьютерщик и профессор Стэнфордского университета:

“Преждевременная оптимизация-корень всех зол.”

Решение должно работать, чтобы оно было оптимизировано.

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

Профилирование может быть сложным делом и занимать много времени, а если оно выполняется вручную, то некоторые проблемы, влияющие на производительность, могут быть пропущены. Для этого различные инструменты, которые могут помочь профилировать код быстрее и эффективнее, включают в себя:

  • PyCallGraph – который создает визуализации графа вызовов, представляющие отношения вызова между подпрограммами для кода Python.
  • profile – который будет описывать, как часто и как долго выполняются различные части кода Python.
  • gProf2dot – это библиотека, которая визуализирует выходные данные профилировщиков в виде точечного графика.

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

Выбор структур данных и потока управления

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

Профилирование может оказать большую помощь в определении наилучшей структуры данных для использования в различных точках нашего кода Python. Много ли мы делаем вставок? Часто ли мы удаляем? Постоянно ли мы ищем предметы? Такие вопросы могут помочь нам выбрать правильную структуру данных для данной потребности и, следовательно, привести к оптимизированному коду Python.

Время и использование памяти будут сильно зависеть от нашего выбора структуры данных. Важно также отметить, что некоторые структуры данных реализуются по – разному в разных языках программирования.

Для понимания цикла и списка

Циклы часто встречаются при разработке на Python, и довольно скоро вы столкнетесь с пониманием списков, которые являются кратким способом создания новых списков, которые также поддерживают условия.

Например, если мы хотим получить список квадратов всех четных чисел в определенном диапазоне, используя цикл for :

new_list = []
for n in range(0, 10):
    if n % 2 == 0:
        new_list.append(n**2)

A List Comprehension версия цикла будет просто:

new_list = [ n**2 for n in range(0,10) if n%2 == 0]

Понимание списка короче и лаконичнее, но это не единственный трюк в рукаве. Они также заметно быстрее по времени выполнения, чем для циклов. Мы будем использовать модуль Timeit , который обеспечивает способ определения времени небольших битов кода Python.

Давайте сопоставим понимание списка с эквивалентным циклом for и посмотрим, сколько времени потребуется каждому из них для достижения одного и того же результата:

import timeit

def for_square(n):
    new_list = []
    for i in range(0, n):
        if i % 2 == 0:
            new_list.append(n**2)
    return new_list

def list_comp_square(n):
    return [i**2 for i in range(0, n) if i % 2 == 0]

print("Time taken by For Loop: {}".format(timeit.timeit('for_square(10)', 'from __main__ import for_square')))

print("Time taken by List Comprehension: {}".format(timeit.timeit('list_comp_square(10)', 'from __main__ import list_comp_square')))

После запуска скрипта 5 раз с помощью Python 2:

$ python for-vs-lc.py 
Time taken by For Loop: 2.56907987595
Time taken by List Comprehension: 2.01556396484
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.37083697319
Time taken by List Comprehension: 1.94110512733
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.52163410187
Time taken by List Comprehension: 1.96427607536
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.44279003143
Time taken by List Comprehension: 2.16282701492
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.63641500473
Time taken by List Comprehension: 1.90950393677

Хотя разница не постоянна, понимание списка занимает меньше времени, чем цикл for . В маломасштабном коде это может не иметь большого значения, но при крупномасштабном выполнении это может быть все, что нужно для экономии времени.

Если мы увеличим диапазон квадратов от 10 до 100, разница станет более очевидной:

$ python for-vs-lc.py 
Time taken by For Loop: 16.0991549492
Time taken by List Comprehension: 13.9700510502
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 16.6425571442
Time taken by List Comprehension: 13.4352738857
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 16.2476081848
Time taken by List Comprehension: 13.2488780022
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 15.9152050018
Time taken by List Comprehension: 13.3579590321

профиль-это профилировщик, который поставляется вместе с Python, и если мы используем его для профилирования нашего кода:

анализ профиля

При дальнейшем рассмотрении мы все еще можем видеть, что инструмент cProfile сообщает, что наше понимание списка занимает меньше времени выполнения, чем наша реализация For Loop , как мы установили ранее. cProfile отображает все вызываемые функции, количество раз, когда они были вызваны, и количество времени, затраченного каждой из них.

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

Но что, если мы обеспокоены использованием нашей памяти? Понимание списка потребует больше памяти для удаления элементов в списке, чем обычный цикл. Понимание списка всегда создает новый список в памяти после завершения, поэтому для удаления элементов из списка будет создан новый список. В то время как для обычного цикла for мы можем использовать list.remove() или list.pop() для изменения исходного списка вместо создания нового в памяти.

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

Связанные списки

Еще одна структура данных, которая может пригодиться для экономии памяти, – это Связанный список . Он отличается от обычного массива тем, что каждый элемент или узел имеет ссылку или указатель на следующий узел в списке и не требует выделения непрерывной памяти.

Массив требует, чтобы память, необходимая для его хранения и его элементов, была выделена заранее, и это может быть довольно дорого или расточительно, когда размер массива заранее не известен.

Связанный список позволит вам выделять память по мере необходимости. Это возможно, потому что узлы в связанном списке могут храниться в разных местах памяти, но объединяться в связанном списке с помощью указателей. Это делает связанные списки намного более гибкими по сравнению с массивами.

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

Диапазон против диапазона

Когда мы имеем дело с циклами в Python, иногда нам нужно будет сгенерировать список целых чисел, чтобы помочь нам в выполнении for-циклов. Для этого используются функции range и xrange .

Их функциональность одинакова, но они отличаются тем, что range возвращает объект list , а xrange возвращает объект xrange .

Что это значит? Объект xrange является генератором в том смысле, что он не является окончательным списком. Это дает нам возможность генерировать значения в ожидаемом конечном списке по мере необходимости во время выполнения с помощью метода, известного как “уступка”.

Тот факт, что функция xrange не возвращает окончательный список, делает ее более эффективным выбором памяти для генерации огромных списков целых чисел для целей цикла.

Если нам нужно сгенерировать большое количество целых чисел для использования, то xrange должен быть нашим вариантом перехода для этой цели, так как он использует меньше памяти. Если вместо этого мы будем использовать функцию range , то потребуется создать весь список целых чисел, и это потребует больших затрат памяти.

Давайте рассмотрим эту разницу в потреблении памяти между двумя функциями:

$ python
Python 2.7.10 (default, Oct 23 2015, 19:19:21) 
[GCC 4.2.1 Compatible Apple LLVM 7.0.0 (clang-700.0.59.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> 
>>> r = range(1000000)
>>> x = xrange(1000000)
>>> 
>>> print(sys.getsizeof(r))
8000072
>>> 
>>> print(sys.getsizeof(x))
40
>>> 
>>> print(type(r))

>>> print(type(x))

Мы создаем диапазон из 1 000 000 целых чисел, используя range и range . Тип объекта, созданного функцией range , – это List , который потребляет 8000072 байта памяти, в то время как объект xrange потребляет только 40 байт памяти.

Функция xrange экономит нам память, загружает ее, но как насчет времени поиска элемента? Давайте определим время поиска целого числа в сгенерированном списке целых чисел с помощью Time it:

import timeit

r = range(1000000)
x = xrange(1000000)

def lookup_range():
    return r[999999]

def lookup_xrange():
    return x[999999]

print("Look up time in Range: {}".format(timeit.timeit('lookup_range()', 'from __main__ import lookup_range')))

print("Look up time in Xrange: {}".format(timeit.timeit('lookup_xrange()', 'from __main__ import lookup_xrange')))

Результат:

$ python range-vs-xrange.py 
Look up time in Range: 0.0959858894348
Look up time in Xrange: 0.140854120255
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.111716985703
Look up time in Xrange: 0.130584001541
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.110965013504
Look up time in Xrange: 0.133008003235
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.102388143539
Look up time in Xrange: 0.133061170578

range может потреблять меньше памяти, но требует больше времени, чтобы найти в нем элемент. Учитывая ситуацию и доступные ресурсы, мы можем выбрать либо range , либо xrange в зависимости от аспекта, который мы ищем. Это подтверждает важность профилирования в оптимизации нашего кода на Python.

Примечание: xrange устарел в Python 3, и функция range теперь может выполнять ту же функцию. Генераторы по-прежнему доступны на Python 3 и могут помочь нам сэкономить память другими способами, такими как Генераторные понимания или выражения .

Наборы

При работе со списками в Python мы должны иметь в виду, что они допускают дублирование записей. Что, если имеет значение, содержат ли наши данные дубликаты или нет?

Вот тут-то и появляются наборы Python. Они похожи на списки, но не позволяют хранить в них дубликаты. Наборы также используются для эффективного удаления дубликатов из списков и быстрее, чем создание нового списка и заполнение его из списка с дубликатами.

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

Давайте сравним эти две операции:

import timeit

# here we create a new list and add the elements one by one
# while checking for duplicates
def manual_remove_duplicates(list_of_duplicates):
    new_list = []
    [new_list.append(n) for n in list_of_duplicates if n not in new_list]
    return new_list

# using a set is as simple as
def set_remove_duplicates(list_of_duplicates):
    return list(set(list_of_duplicates))

list_of_duplicates = [10, 54, 76, 10, 54, 100, 1991, 6782, 1991, 1991, 64, 10]

print("Manually removing duplicates takes {}s".format(timeit.timeit('manual_remove_duplicates(list_of_duplicates)', 'from __main__ import manual_remove_duplicates, list_of_duplicates')))

print("Using Set to remove duplicates takes {}s".format(timeit.timeit('set_remove_duplicates(list_of_duplicates)', 'from __main__ import set_remove_duplicates, list_of_duplicates')))

После запуска скрипта пять раз:

$ python sets-vs-lists.py 
Manually removing duplicates takes 2.64614701271s
Using Set to remove duplicates takes 2.23225092888s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.65356898308s
Using Set to remove duplicates takes 1.1165189743s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.53129696846s
Using Set to remove duplicates takes 1.15646100044s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.57102680206s
Using Set to remove duplicates takes 1.13189387321s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.48338890076s
Using Set to remove duplicates takes 1.20611810684s

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

Это может быть полезно при фильтрации записей для конкурса бесплатных раздач, где мы должны отфильтровывать дубликаты записей. Если требуется 2 секунды, чтобы отфильтровать 120 записей, представьте, что вы отфильтровали 10 000 записей. В таком масштабе значительно увеличивается производительность, которая поставляется с наборами.

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

Конкатенация строк

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

Мы можем использовать + (плюс) для соединения строк. Это идеально подходит для нескольких строковых объектов и не в масштабе. Если вы используете оператор + для объединения нескольких строк, то каждая конкатенация создаст новый объект, поскольку строки неизменяемы. Это приведет к созданию многих новых строковых объектов в памяти, следовательно, неправильному использованию памяти.

Мы также можем использовать оператор конкатенации += для объединения строк, но это работает только для двух строк одновременно, в отличие от оператора + , который может объединять более двух строк.

Если у нас есть итератор, такой как список с несколькими строками, идеальный способ объединить их-это использовать метод .join () .

Давайте создадим список из тысячи слов и сравним, как сравниваются оператор . join() и оператор += :

import timeit

# create a list of 1000 words
list_of_words = ["foo "] * 1000

def using_join(list_of_words):
    return "".join(list_of_words)

def using_concat_operator(list_of_words):
    final_string = ""
    for i in list_of_words:
        final_string += i
    return final_string

print("Using join() takes {} s".format(timeit.timeit('using_join(list_of_words)', 'from __main__ import using_join, list_of_words')))

print("Using += takes {} s".format(timeit.timeit('using_concat_operator(list_of_words)', 'from __main__ import using_concat_operator, list_of_words')))

После двух попыток:

$ python join-vs-concat.py 
Using join() takes 14.0949640274 s
Using += takes 79.5631570816 s
$ 
$ python join-vs-concat.py 
Using join() takes 13.3542580605 s
Using += takes 76.3233859539 s

Очевидно, что метод .join() не только более аккуратен и удобочитаем, но и значительно быстрее оператора конкатенации при соединении строк в итераторе.

Если вы выполняете много операций конкатенации строк, то пользоваться преимуществами подхода, который почти в 7 раз быстрее, просто замечательно.

Вывод

Мы установили, что оптимизация кода имеет решающее значение в Python, а также увидели разницу, возникающую при его масштабировании. С помощью модуля Timeit и профилировщика cProfile мы смогли определить, какая реализация занимает меньше времени, и подкрепили ее цифрами. Структуры данных и структуры потоков управления, которые мы используем, могут сильно повлиять на производительность нашего кода, и мы должны быть более осторожны.

Профилирование также является важным шагом в оптимизации кода, поскольку оно направляет процесс оптимизации и делает его более точным. Мы должны быть уверены, что наш код работает и корректен, прежде чем оптимизировать его, чтобы избежать преждевременной оптимизации, которая может оказаться более дорогостоящей в обслуживании или затруднит понимание кода.