Лайфхаки Python: сэкономить память и ускорить выполнение программы
Python часто ругают за то, что он медленный. Однако в нем существует несколько подходов, которые позволяют писать достаточно быстрый код. Сегодня поговорим про обработку списков.
TL;DR Используйте списковые включения (list comprehensions), генераторные выражения (generator expressions) и генераторы везде, где только можно. Это поможет сэкономить память и время выполнения программы.
Списковые включения (List comprehensions)
Например у нас есть большой список словарей (объявления контекстной рекламы):
import timeit
import time
import itertools
import random
now = time.time()
ads_list = list({’bid’: random.randint(1, 1000), ’date’: now — i, ’id’: 1000000 — i} for i in range(1000000))
Зададим начальное время выборки и конечное
date_start = now — 60*60*24*7
date_end = now — 60*60*24*3
И попробуем выбрать все объявления, ставка которых выше 600 и дата попадает в выбранный интервал. Затем возьмем первые 1000 элементов полученного списка. Сначала решим задачу «в лоб»:
def many_lists():
expensive_ads = [a for a in ads_list if a[’bid’] > 600]
expensive_in_interval = [a for a in expensive_ads if a[’date’] > date_start and a[’date’]
Ок, попробуем немного оптимизировать и сделаем проверку даты там же, где и ставки:
def many_lists_improved():
expensive_ads = [a for a in ads_list if a[’bid’] > 600 and a[’date’] > date_start and a[’date’]
Уже лучше, но не сильно лучше.
Генераторные выражения (generator expressions)
Попробуем использовать генераторные выражения (для получения среза будем использовать функцию islice из itertools, которая возвращает итератор по срезу):
def generators():
expensive_ads = (a for a in ads_list if a[’bid’] > 600)
expensive_in_interval = (a for a in expensive_ads if a[’date’] > date_start and a[’date’]
Итог: увеличение производительности более чем в 3 раза.
Генераторные фунции (generator functions)
Если предикатов фильтрации или обработчиков элементов списка много, то удобнее использовать генераторы. Они могут не дать прироста скорости, но помогут сэкономить память.
Генераторной фунцией в python называется функция, которая ведет себя как итератор. Для определения генераторной функции нужно использовать ключевое слово yield:
def count_five():
for i in range(5):
yield i
Например у нас есть список кортежей (чтобы не было соблазна менять словарь на месте) с данными объявлений, и мы хотим выбрать все объявления из списка, попадающие в наш интервал, а также протэгировать по условиям ставки.
Попробуем для начала решить задачу в лоб и в каждой функции-обработчике возвращать новый список, а затем решим задачу с помощью генераторов:
import sys
import time
import random
import psutil
from timeit import timeit
now = time.time()
ads_list = list((1000000 — i, now — i, random.randint(1, 1000)) for i in range(1000000))
date_end = now — 60*60*24*3
date_start = now — 60*60*24*7
def below_3days(ad_list):
return [a for a in ad_list if a[1] date_start]
def tag_bid(ad_list):
return [(a[0], a[1], a[2] > 500 and ’expensive’ or ’cheap’) for a in ad_list]
def above_7days_gen(ad_list):
for a in ad_list:
if a[1] > date_start:
yield a
def below_3days_gen(ad_list):
for a in ad_list:
if a[1] 500 and ’expensive’ or ’cheap’
yield (a[0], a[1], tag)
list_pipeline = lambda ads: below_3days(above_7days(tag_bid(ads)))
gen_pipeline = lambda ads: below_3days_gen(above_7days_gen(tag_bid_gen(ads)))
pipelines = {
’list’: list_pipeline,
’gen’: gen_pipeline
}
def run_pipeline(ad_list, pipeline):
processed = pipeline(ad_list)
return sorted(processed, key=lambda item: item[0])
if __name__ == ’__main__’:
try:
pipeline = pipelines[sys.argv[1]]
except (IndexError, KeyError):
print(’invalid arguments, use `list` or `gen` to choose pipeline’)
sys.exit(1)
process = psutil.Process()
mem_before = process.memory_info().rss
tm = timeit(lambda: run_pipeline(ads_list, pipeline), number=1)
consumption = process.memory_info().rss — mem_before
print(’consumption:’, consumption/1024, ’KB, in’, round(tm, 2), ’s’)
Запустим наш скрипт сначала с ключом list:
python run_pipeline.py list
consumption: 15568.0 KB, in 0.25 s
А потом с ключом gen:
python run_pipeline.py gen
consumption: 6112.0 KB, in 0.25 s
Как можно увидеть, скорость выполнения не изменилась, но мы сэкономили память (почти трехкратная разница), потому что генераторы не создают новых списков, а обрабатывают один элемент за итерацию.
И напоследок
Не всегда операторы в python ведут себя так, как мы привыкли. Например сложение списков:
def plus():
list1 = list(range(1000000))
list2 = list(range(1000000))
list1 = list1 + list2
return list1
def plus_eq():
list1 = list(range(1000000))
list2 = list(range(1000000))
list1 += list2
return list1
timeit.timeit(plus, number=10)
0.4108163330001844
timeit.timeit(plus_eq, number=10)
0.3518642500000624
Посмотрим дизассемблером, что происходит внутри этих функций:
import dis
dis.dis(plus)
2 0 LOAD_GLOBAL 0 (list)
2 LOAD_GLOBAL 1 (range)
4 LOAD_CONST 1 (1000000)
6 CALL_FUNCTION 1
8 CALL_FUNCTION 1
10 STORE_FAST 0 (list1)
12 LOAD_GLOBAL 0 (list)
14 LOAD_GLOBAL 1 (range)
16 LOAD_CONST 1 (1000000)
18 CALL_FUNCTION 1
20 CALL_FUNCTION 1
22 STORE_FAST 1 (list2)
24 LOAD_FAST 0 (list1)
26 LOAD_FAST 1 (list2)
28 BINARY_ADD
30 STORE_FAST 0 (list1)
32 LOAD_FAST 0 (list1)
34 RETURN_VALUE
dis.dis(plus_eq)
2 0 LOAD_GLOBAL 0 (list)
2 LOAD_GLOBAL 1 (range)
4 LOAD_CONST 1 (1000000)
6 CALL_FUNCTION 1
8 CALL_FUNCTION 1
10 STORE_FAST 0 (list1)
12 LOAD_GLOBAL 0 (list)
14 LOAD_GLOBAL 1 (range)
16 LOAD_CONST 1 (1000000)
18 CALL_FUNCTION 1
20 CALL_FUNCTION 1
22 STORE_FAST 1 (list2)
24 LOAD_FAST 0 (list1)
26 LOAD_FAST 1 (list2)
28 INPLACE_ADD
30 STORE_FAST 0 (list1)
32 LOAD_FAST 0 (list1)
34 RETURN_VALUE
Как видно, инструкция 28 в случае `+` простое сложение, а в случае `+=` — сложение на месте, которое не приводит к созданию нового списка. += в данном случае сопоставим по производительности с list.extend:
def extend():
list1 = list(range(1000000))
list2 = list(range(1000000))
list1.extend(list2)
return list1
timeit.timeit(extend, number=10)
0.3511457080001037
Заключение
Генераторы и итераторы помогают сэкономить память или время выполнения, но у них есть и особенности, из-за которых они могут не подойти. Например, по генераторам можно итерироваться только один раз, в отличие от списков.
Выводы
На примерах выше мы увидели, что python предоставляет нам некоторую возможность для маневра при обработке списков, главное знать об этих возможностях и использовать их там, где они подходят.
https://t.me/python_job_interview