🐍⚙️ 10 способов оптимизации Python-кода
Python ценят за простоту, гибкость и читаемость, но критикуют за невысокую производительность. Эта критика не всегда обоснована: есть несколько эффективных способов значительно повысить скорость Python-приложений, предназначенных для выполнения сложных вычислений и обработки больших объемов данных.
Упаковка переменных
Упаковка переменных — это процесс минимизации использования памяти за счет объединения нескольких элементов данных в одну структуру. Эта техника особенно важна в сценариях, где время доступа к памяти сильно влияет на производительность, например, при обработке больших объемов связанных данных: упаковка позволяет значительно ускорить процесс с помощью эффективного использования кэша процессора.
В Python для упаковки данных в компактный бинарный формат используется модуль struct:
import struct import random import sys nums = [random.randint(0, 1000000) for _ in range(5000)] packed_nums = struct.pack(f'{len(nums)}i', *nums) original_size_nums = sys.getsizeof(nums) packed_size_nums = sys.getsizeof(packed_nums) efficiency = (1 - packed_size_nums / original_size_nums) * 100 print(f'Исходный размер: {original_size_nums} байт') print(f'Упакованный размер: {packed_size_nums} байт') print(f'Эффективность сжатия: {efficiency:.2f}%') unpacked_nums = struct.unpack(f'{len(nums)}i', packed_nums) for i in range(0, 5000, 100): print(f"Проверяем число с индексом {i}:") print(f"Исходное число: {nums[i]}, распакованное число: {unpacked_nums[i]}") assert nums[i] == unpacked_nums[i], f"Несовпадение чисел по индексу {i}" print("Все числа совпали")
Результат:
Исходный размер: 41880 байт Упакованный размер: 20033 байт Эффективность сжатия: 52.17% Проверяем число с индексом 0: Исходное число: 538873, распакованное число: 538873 Проверяем число с индексом 100: Исходное число: 918169, распакованное число: 918169 Проверяем число с индексом 200: Исходное число: 663463, распакованное число: 663463 Проверяем число с индексом 300: Исходное число: 335298, распакованное число: 335298 Проверяем число с индексом 400: Исходное число: 103470, распакованное число: 103470 Проверяем число с индексом 500: Исходное число: 537139, распакованное число: 537139 Проверяем число с индексом 600: Исходное число: 816242, распакованное число: 816242 Проверяем число с индексом 700: Исходное число: 736703, распакованное число: 736703 Проверяем число с индексом 800: Исходное число: 134686, распакованное число: 134686 Проверяем число с индексом 900: Исходное число: 198001, распакованное число: 198001 Проверяем число с индексом 1000: Исходное число: 833857, распакованное число: 833857 Проверяем число с индексом 1100: Исходное число: 230153, распакованное число: 230153 Проверяем число с индексом 1200: Исходное число: 728830, распакованное число: 728830 Проверяем число с индексом 1300: Исходное число: 641456, распакованное число: 641456 Проверяем число с индексом 1400: Исходное число: 794241, распакованное число: 794241 Проверяем число с индексом 1500: Исходное число: 389231, распакованное число: 389231 Проверяем число с индексом 1600: Исходное число: 455378, распакованное число: 455378 Проверяем число с индексом 1700: Исходное число: 876660, распакованное число: 876660 Проверяем число с индексом 1800: Исходное число: 812566, распакованное число: 812566 Проверяем число с индексом 1900: Исходное число: 468887, распакованное число: 468887 Проверяем число с индексом 2000: Исходное число: 769358, распакованное число: 769358 Проверяем число с индексом 2100: Исходное число: 8201, распакованное число: 8201 Проверяем число с индексом 2200: Исходное число: 977281, распакованное число: 977281 Проверяем число с индексом 2300: Исходное число: 629243, распакованное число: 629243 Проверяем число с индексом 2400: Исходное число: 117519, распакованное число: 117519 Проверяем число с индексом 2500: Исходное число: 229750, распакованное число: 229750 Проверяем число с индексом 2600: Исходное число: 833149, распакованное число: 833149 Проверяем число с индексом 2700: Исходное число: 764713, распакованное число: 764713 Проверяем число с индексом 2800: Исходное число: 174090, распакованное число: 174090 Проверяем число с индексом 2900: Исходное число: 95317, распакованное число: 95317 Проверяем число с индексом 3000: Исходное число: 241478, распакованное число: 241478 Проверяем число с индексом 3100: Исходное число: 197858, распакованное число: 197858 Проверяем число с индексом 3200: Исходное число: 152379, распакованное число: 152379 Проверяем число с индексом 3300: Исходное число: 147324, распакованное число: 147324 Проверяем число с индексом 3400: Исходное число: 561581, распакованное число: 561581 Проверяем число с индексом 3500: Исходное число: 97425, распакованное число: 97425 Проверяем число с индексом 3600: Исходное число: 92997, распакованное число: 92997 Проверяем число с индексом 3700: Исходное число: 106960, распакованное число: 106960 Проверяем число с индексом 3800: Исходное число: 197652, распакованное число: 197652 Проверяем число с индексом 3900: Исходное число: 380352, распакованное число: 380352 Проверяем число с индексом 4000: Исходное число: 732233, распакованное число: 732233 Проверяем число с индексом 4100: Исходное число: 274363, распакованное число: 274363 Проверяем число с индексом 4200: Исходное число: 131550, распакованное число: 131550 Проверяем число с индексом 4300: Исходное число: 628213, распакованное число: 628213 Проверяем число с индексом 4400: Исходное число: 403623, распакованное число: 403623 Проверяем число с индексом 4500: Исходное число: 583847, распакованное число: 583847 Проверяем число с индексом 4600: Исходное число: 697159, распакованное число: 697159 Проверяем число с индексом 4700: Исходное число: 699888, распакованное число: 699888 Проверяем число с индексом 4800: Исходное число: 242131, распакованное число: 242131 Проверяем число с индексом 4900: Исходное число: 463454, распакованное число: 463454 Все числа совпали
Ускорение обработки файлов
В приложениях, для которых критически важна производительность, необходимо хранить часто используемые данные в памяти и минимизировать операции чтения/записи на диск, чтобы обеспечить высокую скорость извлечения информации. Модуль mmap позволяет работать с файлами на диске, как если бы они находились в оперативной памяти:
import mmap def print_specific_lines(filename, lines_to_print): with open(filename, "r+b") as f: mmapped_file = mmap.mmap(f.fileno(), 0) lines = mmapped_file.read().decode('utf-8').splitlines() for i in range(99, len(lines), 100): if len(lines[i]) != 0: print(f"Строка {i + 1}: {lines[i]}") for i in range(499, len(lines), 500): if len(lines[i]) != 0: print(f"Строка {i + 1}: {lines[i]}") mmapped_file.close() print_specific_lines("alice.txt", [100, 500])
Результат — вывод каждой 100-й и каждой 500-й непустой строки файла:
Строка 400: being drowned in my own tears! That _will_ be a queer thing, to be Строка 500: “Ahem!” said the Mouse with an important air, “are you all ready? This Строка 700: little door, had vanished completely. Строка 1000: good reason, and as the Caterpillar seemed to be in a _very_ unpleasant Строка 1100: rearing itself upright as it spoke (it was exactly three inches high). Строка 1200: “I—I’m a little girl,” said Alice, rather doubtfully, as she remembered Строка 1500: a Hatter: and in _that_ direction,” waving the other paw, “lives a Строка 1600: “Then it wasn’t very civil of you to offer it,” said Alice angrily. Строка 2000: their faces, and the pattern on their backs was the same as the rest of Строка 2200: the question, and they repeated their arguments to her, though, as they Строка 2400: she was out of sight: then it chuckled. “What fun!” said the Gryphon, Строка 2600: “Back to land again, and that’s all the first figure,” said the Mock Строка 2700: “They were obliged to have him with them,” the Mock Turtle said: “no Строка 2900: and she could even make out that one of them didn’t know how to spell Строка 3000: “I’m a poor man, your Majesty,” the Hatter began, in a trembling voice, Строка 3200: “What’s in it?” said the Queen. Строка 3300: “All right, so far,” said the King, and he went on muttering over the Строка 3400: their simple joys, remembering her own child-life, and the happy summer Строка 3500: 1.E.1. The following sentence, with active links to, or other Строка 3700: Section 4. Information about Donations to the Project Gutenberg Строка 500: “Ahem!” said the Mouse with an important air, “are you all ready? This Строка 1000: good reason, and as the Caterpillar seemed to be in a _very_ unpleasant Строка 1500: a Hatter: and in _that_ direction,” waving the other paw, “lives a Строка 2000: their faces, and the pattern on their backs was the same as the rest of Строка 3000: “I’m a poor man, your Majesty,” the Hatter began, in a trembling voice, Строка 3500: 1.E.1. The following sentence, with active links to, or other
Экономия памяти с array.array
Бытует мнение, что array.array работает значительно быстрее, чем обычный list. Это не так: в современных версиях Python список list максимально оптимизирован, и по производительности превосходит array.array в ходе выполнения абсолютного большинста операций — как видно по приведенному ниже примеру, list уступает только в случае с сериализацией. Но вот памяти массивы array.array действительно используют меньше — почти в два раза:
import array import time import sys arr = array.array('i', range(1000000)) lst = list(range(1000000)) # Сравнение размера в памяти print(f"Размер array.array: {sys.getsizeof(arr)} байт") print(f"Размер list: {sys.getsizeof(lst)} байт") # Сравнение скорости доступа def access_test(container): start = time.time() for _ in range(1000000): _ = container[500000] return time.time() - start print(f"Время доступа для array.array: {access_test(arr):.6f} сек") print(f"Время доступа для list: {access_test(lst):.6f} сек") # Сравнение скорости числовых операций def numeric_operation_test(container): start = time.time() for i in range(len(container)): container[i] *= 2 return time.time() - start arr_copy = array.array('i', arr) lst_copy = list(lst) print(f"Время числовых операций для array.array: {numeric_operation_test(arr_copy):.6f} сек") print(f"Время числовых операций для list: {numeric_operation_test(lst_copy):.6f} сек") # Сериализация import pickle start = time.time() pickle.dumps(arr) print(f"Время сериализации array.array: {time.time() - start:.6f} сек") start = time.time() pickle.dumps(lst) print(f"Время сериализации list: {time.time() - start:.6f} сек")
Результат:
Размер array.array: 4091948 байт Размер list: 8000056 байт Время доступа для array.array: 0.230873 сек Время доступа для list: 0.112998 сек Время числовых операций для array.array: 0.622823 сек Время числовых операций для list: 0.385629 сек Время сериализации array.array: 0.008033 сек Время сериализации list: 0.030163 сек
Внутренние функции
В Python можно разделять функции на внутренние и публичные для оптимизации производительности и удобства использования. Внутренние функции обычно используются только внутри модуля, где они определены, и оптимизированы для быстроты и эффективности. Публичные функции, напротив, предназначены для внешнего использования и могут включать дополнительную проверку данных, логирование и другие механизмы, обеспечивающие удобство и безопасность использования:
import string import logging # Настройка логирования logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # Внутренняя функция для очистки строки от знаков препинания def _clean_string(input_string): translator = str.maketrans('', '', string.punctuation) cleaned_string = input_string.translate(translator) return cleaned_string def count_characters(input_string): logger.info(f"Вызвана функция count_characters с аргументом: {input_string}") try: cleaned_string = _clean_string(input_string) result = len(cleaned_string) logger.info(f"Функция count_characters вернула результат: {result}") return result except Exception as e: logger.error(f"Ошибка в функции count_characters: {e}") return 0 def count_words(input_string): logger.info(f"Вызвана функция count_words с аргументом: {input_string}") try: cleaned_string = _clean_string(input_string) result = len(cleaned_string.split()) logger.info(f"Функция count_words вернула результат: {result}") return result except Exception as e: logger.error(f"Ошибка в функции count_words: {e}") return 0 def reverse_string(input_string): logger.info(f"Вызвана функция reverse_string с аргументом: {input_string}") try: cleaned_string = _clean_string(input_string) result = cleaned_string[::-1] logger.info(f"Функция reverse_string вернула результат: {result}") return result except Exception as e: logger.error(f"Ошибка в функции reverse_string: {e}") return "" input_data = input() print("Оригинальная строка:", input_data) print("Количество символов:", count_characters(input_data)) print("Количество слов:", count_words(input_data)) print("Строка в обратном порядке:", reverse_string(input_data))
Результат:
Оригинальная строка: Привет, мир!!! Это - текстовая строка (для демонстрации). 2024-08-28 21:12:09,353 - INFO - Вызвана функция count_characters с аргументом: Привет, мир!!! Это - текстовая строка (для демонстрации). 2024-08-28 21:12:09,353 - INFO - Функция count_characters вернула результат: 49 Количество символов: 49 2024-08-28 21:12:09,353 - INFO - Вызвана функция count_words с аргументом: Привет, мир!!! Это - текстовая строка (для демонстрации). 2024-08-28 21:12:09,353 - INFO - Функция count_words вернула результат: 7 Количество слов: 7 2024-08-28 21:12:09,353 - INFO - Вызвана функция reverse_string с аргументом: Привет, мир!!! Это - текстовая строка (для демонстрации). 2024-08-28 21:12:09,353 - INFO - Функция reverse_string вернула результат: иицартсномед ялд акортс яавотскет отЭ рим тевирП Строка в обратном порядке: иицартсномед ялд акортс яавотскет отЭ рим тевирП
Декораторы
Декораторы позволяют расширить поведение функции, не изменяя ее исходный код:
- Декоратор принимает функцию в качестве входных данных.
- Добавляет функциональность, может выполнить какой-то код до и/или после вызова оригинальной функции.
- Возвращает модифицированную функцию, которая включает в себя оригинальную функцию плюс дополнительную функциональность.
Декораторы часто используют для:
- Логирования — добавляют записи о вызовах функции, ее аргументах и результатах.
- Кэширования — сохраняют промежуточные результаты работы функции, чтобы избежать повторных вычислений.
- Проверки прав пользователя перед выполнением функции.
- Измерения времени выполнения функции.
Этот пример имитирует запросы к внешнему API и показывает, как можно использовать декораторы для кэширования данных и измерения времени выполнения:
import time from functools import lru_cache, wraps import random # Декоратор для измерения времени выполнения def measure_time(func): @wraps(func) def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) end = time.time() print(f"{func.__name__} выполнена за {end - start:.2f} сек") return result return wrapper # Имитация внешнего API с задержкой def slow_api_request(user_id): time.sleep(2) return { "id": user_id, "name": f"User {user_id}", "last_login": time.time(), "score": random.randint(1, 100) } # Функция без кэширования @measure_time def get_user_data(user_id): print(f"Запрос данных для пользователя {user_id}") return slow_api_request(user_id) # Функция с кэшированием @measure_time @lru_cache(maxsize=100) def get_user_data_cached(user_id): print(f"Запрос данных для пользователя {user_id}") return slow_api_request(user_id) def run_test(): user_ids = [1, 2, 3, 1, 2, 1, 1, 3] print("Без кэширования:") for user_id in user_ids: get_user_data(user_id) print("\nС кэшированием:") for user_id in user_ids: get_user_data_cached(user_id) run_test()
Результат:
Без кэширования: Запрос данных для пользователя 1 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 2 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 3 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 1 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 2 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 1 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 1 get_user_data выполнена за 2.00 сек Запрос данных для пользователя 3 get_user_data выполнена за 2.00 сек С кэшированием: Запрос данных для пользователя 1 get_user_data_cached выполнена за 2.00 сек Запрос данных для пользователя 2 get_user_data_cached выполнена за 2.00 сек Запрос данных для пользователя 3 get_user_data_cached выполнена за 2.00 сек get_user_data_cached выполнена за 0.00 сек get_user_data_cached выполнена за 0.00 сек get_user_data_cached выполнена за 0.00 сек get_user_data_cached выполнена за 0.00 сек get_user_data_cached выполнена за 0.00 сек
Готовые библиотеки
Многие начинающие питонисты пренебрежительно относятся к использованию готовых библиотек, — считают это читерством. И совершенно зря: высокопроизводительные библиотеки (например, NumPy и pandas) написаны на C и оптимизированы для максимальной производительности на низком уровне: они эффективно используют память, ресурсы CPU, применяют векторизацию и т.д. В последнее время суперскоростные библиотеки для Python (polars, к примеру) пишут на Rust, у которого еще больше преимуществ, чем у C. Появляются альтернативы для NumPy и pandas, которые могут использовать возможности GPU (например, CuPy) . Всегда, когда это возможно, стоит пользоваться готовыми библиотеками, особенно для ресурсоемких вычислений — это гораздо эффективнее, чем стандартный Python:
import numpy as np import timeit def generate_random_data(size): return np.random.rand(size) # Функция для вычисления суммы квадратов с помощью NumPy def sum_squares_numpy(arr): return np.sum(arr**2) # Функция для вычисления суммы квадратов с помощью обычного Python-цикла def sum_squares_python(arr): result = 0 for num in arr: result += num**2 return result def test_performance(): size = 5000000 arr = generate_random_data(size) print(f"\nВычисление для массива размером {size}:") # Вычисляем время выполнения для NumPy start_time = timeit.default_timer() sum_squares_numpy(arr) numpy_time = timeit.default_timer() - start_time # Вычисляем время выполнения для Python start_time = timeit.default_timer() sum_squares_python(arr) python_time = timeit.default_timer() - start_time print(f"\nВремя выполнения с NumPy: {numpy_time:.6f} секунд") print(f"Время выполнения с Python: {python_time:.6f} секунд") test_performance()
Результат:
Вычисление для массива размером 5000000: Время выполнения с NumPy: 0.085850 секунд Время выполнения с Python: 1.128655 секунд
Короткое замыкание
Короткое замыкание позволяет избежать ненужных вычислений при проверке условий. Этот подход особенно полезен в сложных проверках или при работе с ресурсоемкими операциями: при проверке условия программа прекращает выполнение сразу после того, как найдет первую переменную, удовлетворяющую условию, поэтому следует располагать условия в порядке вероятности срабатывания. Например, для проверки високосного года условия стоит расположить так:
year % 4 == 0
— это условие проверяется первым, так как оно отсеивает большинство не високосных лет. Если год не делится на 4, функция сразу возвращаетFalse
.year % 100 != 0
— если год прошел первую проверку, мы проверяем, не делится ли он на 100. Это условие встречается реже, чем первое. Если год не делится на 100, он точно високосный, и мы можем вернутьTrue
.year % 400 == 0
— это самое редкое условие, большинство лет до него не доберутся.
def is_leap_year(year): return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
Освобождение памяти
Python автоматически управляет сборкой мусора — когда объекты выходят из зоны видимости (например, функция заканчивает работу), все ненужные переменные удаляются, высвобождая оперативную память. Но в приложениях, которые обрабатывают очень большие объемы данных, стоит удалять ненужные объекты сразу, принудительно вызывая сборщик мусора:
import os import psutil import gc def memory_usage(): process = psutil.Process(os.getpid()) return process.memory_info().rss / (1024 * 1024) # в МБ print(f"Использование памяти до: {memory_usage():.1f} МБ") large_data = [i for i in range(10000000)] print(f"Использование памяти после создания: {memory_usage():.1f} МБ") del large_data gc.collect() print(f"Использование памяти после удаления: {memory_usage():.1f} МБ")
Результат:
Использование памяти до: 20.3 МБ Использование памяти после создания: 402.8 МБ Использование памяти после удаления: 21.5 МБ
В этом примере del
используется в сочетании с gc.collect()
потому, что del
уменьшает счетчик ссылок на объект: когда счетчик достигает нуля, объект становится доступным для сборщика мусора.
Короткие сообщения об ошибках
Для встроенных систем (умных устройств и всевозможных IoT-датчиков) стоит использовать короткие сообщения об ошибках — они экономят память и сетевой трафик, а также ускоряют логирование:
try: result = 10 / 0 except ZeroDivisionError: print("Err: Div/0")
Векторизация вместо циклов и списковых включений
Векторизация позволяет выполнять операции над целыми массивами данных одновременно, вместо того, чтобы обрабатывать каждый элемент по отдельности в цикле (или в списковом включении). В зависимости от платформы и типа задачи, векторизация ускоряет обработку данных в десятки и сотни раз:
import random import time import numpy as np def generate_matrix(size): return [[random.randint(-100, 100) for _ in range(size)] for _ in range(size)] def matrix_operations_with_list_comprehension(A, B): size = len(A) C = [[A[i][j] * B[j][i] for j in range(size)] for i in range(size)] D = [[sum(C[i][k] * B[k][j] for k in range(size)) for j in range(size)] for i in range(size)] E = [[D[i][j] + D[j][i] for j in range(size)] for i in range(size)] return E def matrix_operations_with_loops(A, B): size = len(A) C = [[0] * size for _ in range(size)] D = [[0] * size for _ in range(size)] E = [[0] * size for _ in range(size)] for i in range(size): for j in range(size): C[i][j] = A[i][j] * B[j][i] for i in range(size): for j in range(size): D[i][j] = 0 for k in range(size): D[i][j] += C[i][k] * B[k][j] for i in range(size): for j in range(size): E[i][j] = D[i][j] + D[j][i] return E def matrix_operations_with_numpy(A, B): A = np.array(A) B = np.array(B) C = A * B.T D = C @ B E = D + D.T return E.tolist() def test_performance(): size = 400 print(f"\nВычисления для матриц размером {size}x{size}:") A = generate_matrix(size) B = generate_matrix(size) start_time = time.time() E_loop = matrix_operations_with_loops(A, B) loops_time = time.time() - start_time start_time = time.time() E_list = matrix_operations_with_list_comprehension(A, B) list_time = time.time() - start_time start_time = time.time() E_numpy = matrix_operations_with_numpy(A, B) numpy_time = time.time() - start_time print(f"\nВремя выполнения с обычными циклами: {loops_time:.6f} секунд") print(f"Время выполнения со списковыми включениями: {list_time:.6f} секунд") print(f"Время выполнения с NumPy: {numpy_time:.6f} секунд") test_performance()
Результат:
Вычисления для матриц размером 400x400: Время выполнения с обычными циклами: 18.304441 секунд Время выполнения со списковыми включениями: 15.638463 секунд Время выполнения с NumPy: 0.134936 секунд
Подведем итоги
Python — интерпретируемый язык, и по этой причине он всегда будет уступать в скорости низкоуровневым компилируемым языкам типа Rust. Однако считать его безнадежно медленным тоже не стоит:
- Как показывают приведенные выше примеры, использование памяти и сложные вычисления в Python-приложениях можно значительно оптимизировать.
- Есть возможность совместного использования Python и Rust (PyO3).
- Производительность самого Python постоянно повышается — версия 3.11 работала на 10-60% быстрее, чем 3.10; модуль asyncio в версии 3.12 получил 75% прирост скорости.
Стоит также заметить, что иногда лучшим способом оптимизации может стать переосмысление самой задачи и использование принципиально другого подхода к реализации критически важного участка кода, а в этом может помочь хорошее знание готовых алгоритмов и структур данных.
Вы уже видели, как важны оптимизация и производительность в Python. Но прежде чем погрузиться в сложные техники, важно освоить базовые навыки программирования. Курс «Основы программирования на Python» от Proglib Academy предлагает:
- Пошаговое руководство для новичков
- Практические задания для закрепления знаний
- Введение в ключевые библиотеки и инструменты