Написать пост

Лайфхаки Python: сэкономить память и ускорить выполнение программы

Аватарка пользователя Антон Сычугов

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’] < date_end]
    return expensive_in_interval[:1000]


timeit.timeit(many_lists, number=100)
9.126969000000031
		

Ок, попробуем немного оптимизировать и сделаем проверку даты там же, где и ставки:

			def many_lists_improved():
    expensive_ads = [a for a in ads_list if a[’bid’] > 600 and a[’date’] > date_start and a[’date’] < date_end]
    return expensive_ads[:1000]


timeit.timeit(many_lists_improved, number=100)
8.304767416000004
		

Уже лучше, но не сильно лучше.

Генераторные выражения (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’] < date_end)
    return list(itertools.islice(expensive_in_interval, 0, 1000))


timeit.timeit(generators, number=100)
2.422867124999925
		

Итог: увеличение производительности более чем в 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_end] def above_7days(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] < date_end: yield a def tag_bid_gen(ad_list): for a in ad_list: tag = a[2] > 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 предоставляет нам некоторую возможность для маневра при обработке списков, главное знать об этих возможностях и использовать их там, где они подходят.

Следите за новыми постами
Следите за новыми постами по любимым темам
8К открытий8К показов