Аналізуємо використання RAM та CPU в Python
Мене звати Олександр Грицай, я Python-розробник. Моя основна спеціалізація — розробка бекенд-сервісів за допомогою Python, Django, Django REST Framework та FastAPI, а також робота з базами даних PostgreSQL, MySQL і Redis.
До переходу в IT я працював інженером у галузі проєктування інженерних мереж і керівником проєктних груп. У тій сфері часто стикалися з тим, що інформації в публічному доступі було недостатньо, особливо коли йшлося про реальні проєкти та застосовані технології. Це змушувало мене «перезапускати колесо» та придумувати нові рішення, що забирало багато часу й сил, але водночас давало потужний імпульс для розвитку моїх знань і навичок.
Ця стаття — спроба поділитися своїм невеликим досвідом і зрозуміти, як правильний вибір структур даних може вплинути на ефективність роботи програм, особливо в реальних проєктах.
До написання цієї статті мене підштовхнула досить банальна річ — я не знайшов відповіді під час активного рессерчу в Google. Можливо, десь є схожі статті, але я їх не знайшов.
Так що ж я хотів дізнатися? Почалося все з того, як Python менеджить пам’ять. Тут відповідь була швидко знайдена й опрацьована.
Після цього виникло наступне питання — а як це впливає на роботу різних типів та структур даних, як краще зберігати дані, як це впливає на ресурси, час виконання тощо? І ось тут я вже не міг знайти нормальних відповідей...
Всі знайдені відповіді були побудовані на примітивних прикладах, які важко оцінити для реальних проєктів. Найчастіший приклад: сформувати list на 1 мільйон записів від 1 до 1 мільйона, виміряти, скільки він важить, а потім зробити те саме через array.array і продемонструвати, що другий варіант важить набагато менше.
Це мене не зовсім влаштувало, бо я люблю розбиратися в тому, що мене цікавить 🙂
З чого я вирішив почати: створити 100_000 id, ґрунтуючись на uuid4.hex (складається з 32 символів) і потім з цих IDs створити колекції на основі основних структур даних в дайноні — list, dict, set та ... deque (цікаво потім застосувати його в певних сценаріях).
Для початку треба дізнатися, а скільки займає 1 id:
import sys import uuid uuid_hex = uuid.uuid4().hex # 32 symbols uuid_bytes = bytes.fromhex(uuid_hex) # Convert to bytes print(sys.getsizeof(uuid_hex)) # 73 bytes print(sys.getsizeof(uuid_bytes)) # 49 bytes
Виходить, що в чистому вигляді 100_000 id має займати:
print(f"{sys.getsizeof(uuid_hex) * 100_000 / 1024 / 1024:.2f}") # 6.96 MB print(f"{sys.getsizeof(uuid_bytes) * 100_000 / 1024 / 1024:.2f}") # 4.67 MB
Я написав скрипт (код тут), котрий створює в Redis 100_000 ключів, які будуть використовуватися для генерації колекцій. Чому Redis? Мені було так зручніше, хоча можна було б обійтися локальним файлом. Всі ключі отримуються як ітератор, котрий передається в функції для створення колекцій.
Для визначення розміру об’єктів я використовував бібліотеку pympler.
Отримані результати:
- List — 8.59 MB
- Deque — 8.62 MB
- Dict — 11.57 MB
- Set — 11.91 MB
Найбільша технічна конфа ТУТ!🤌
Виходить, що List та Deque — найменші накладні витрати для зберігання простих даних (+/- 25%). А от Dict та Set — майже 75%! На такій кількості IDs це не смертельно, а от на великій кількості даних — зайві витрати як за ресурсами заліза, так і за фінансовими.
Але це не основне, що мене цікавило перед початком експериментів. Основне — які ресурси витрачаються, щоб створити ці колекції. А саме — CPU, RAM та час виконання.
Для більшої наочності я вирішив будувати графік використання CPU та RAM. Для цього написав пару декораторів, котрі вимірюють ці показники (cpu та ram) за допомогою бібліотеки psutil і потім виводять графіки (graphs).
Як виконувалися вимірювання та будувався графік:
- Створення кожної колекції запускалося окремо, щоб не створювати можливих накладань показників.
- Дані зберігалися в Redis.
- Кожну колекцію створював 10 разів і графіки будувалися на середніх показниках.
Як бачимо, час виконання та медіана завантаження ЦП — майже однакові та не має явного лідера. Єдине, що виділяється — піковий стрибок в кінці для List.
По використанню RAM теж не має нічого особливого — +/- всі зростають лінійно. Єдине, що з цікавого: для Dict та Set є два майже однакові стрибки. Можу припустити, що це відбувалася перебудова (збільшення) хеш-таблиці.
Далі я вирішив поглянути на витрати при перевірці, чи міститься ключ з однієї колекції в іншій. Було створено 10_000 IDs, котрі перетиналися з початковими 100_000 IDs. Щоб не збільшувати час на вимірювання, я вирішив перевіряти 10_000 в 100_000, а не навпаки.
Графік CPU тут.
Тут теж нічого дивного не відбулося — Dict та Set відпрацювали за О(1) та виконалися майже миттєво з самими мінімальними затратами ресурсів. Різниця в часі виконання, порівняно з List та Deque, просто величезна — в 44 та 55 разів.
В цьому тесті гарно видно, як можна нашкодити всій програмі, просто вибравши не правильну структуру даних для конкретної задачі.
Далі буде трохи цікавіше — тепер замість простих IDs у вигляді str я використаю дані з реального публічного веб API. З даними API я вже працював і знаю, що кожний респонс може містити різну кількість даних, а це, як на мене, «+» для тестів.
Я зберіг 10_000 різних респонсів, які потім використав для тестів. І ось, що я отримав:
Графік CPU тут.
В цьому випадку варто зазначити, що Set не зовсім чесно знаходиться в цьому тесті. Він не може зберігати змінні об’єкти, то ж зберегти в ньому декодований JSON — неможливо. Тож я зберігав в ньому всі дані у вигляді звичайного string. Але це, може, і на краще — можна побачити різницю в розмірі.
В цьому тесті було виявлено цікавий момент: різниця у витраті RAM для Dict, List та Deque — зникла. На перших тестах, де в них зберігались прості дані у вигляді string довжиною 32 символи, різниця в займаній пам’яті була +/-40%. При зберіганні не пласких, а вже контейнерних об’єктів — різниця повністю зникла. Це пов’язано з тим, що коли ж у структурі зберігаються складні об’єкти (наприклад, словники), всі вони вже існують окремо, і структура даних містить лише посилання на них.
Які фактичні розміри я отримав в кінці:
- List, Deque, Dict — +/-1060 MB
- Set — 755.15 MB
Також в цьому тесті можна побачити різницю в об’ємі займаної пам’яті при використанні — 755 МБ проти 1060 МБ (це майже 45%).
Оскільки я вже працював з цим API, то в мене є декілька валідаторів для підготовки даних, зроблених на Pydantic. Їх я і використав для наступних тестів. А саме я брав готову колекцію з сирими даними, валідував їх і потім зберігав в таку саму структуру даних.
Графік CPU тут.
Тут ми так само бачимо, що Dict, List та Deque мають однакові витрати в часі, RAM та CPU. А от Set потребує набагато менших витрат по пам’яті... але час виконання у 2 рази більший — це пов’язано з тим, що спочатку треба декодувати string, потім валідувати, й потім кодувати знову в string.
Враховуючи всі отримані результати, в мене виникло запитання — а як же економити ресурси?
Явно видно, що використання CPU не змінити, точніше % завантаження не змінити. Залишається тільки RAM та час виконання.
Уявімо собі таку задачу: нам потрібно отримати всі IDs (це ті самі 100_000). Після цього нам потрібно знайти співпадіння ID зі списку, котрий в нас вже є. Коли ми знайдемо всі потрібні IDs (це 10_000), зробимо за ними запити до Redis та отримаємо сирі дані, котрі нам потрібно очистити та повернути.
Було написано сім різних варіантів реалізації цієї задачі. Від самої примітивної й до максимально оптимальної. Код можна подивитися тут.
Переможцем виявився сценарій номер 5. Він найшвидший (без використання багатопоточності) та витрачає менше всього пам’яті.
def scenario_v5(key_with_data: List[str], **kwargs) -> List[str]: all_ids_set = set(create_iterator_from_redis_keys()) clean_data = [] for id_data in set(key_with_data) & all_ids_set: clean_data.append( TenderDataValidator( **get_value_from_redis(key=id_data) ).model_dump_json() ) return clean_data
Чому цей варіант виявився найбільш оптимальним за RAM — він не накопичує зайвих даних. Не створюються зайві змінні з не потрібними даними. Відразу створюється Set зі 100_000 IDs, цикл for виконується по Set’у, котрий утворюється при ініціалізації циклу. Далі дані валідуються, перетворюються в JSON (для подальшого зберігання) і вже кінцевий результат зберігається у List.
А от найшвидшим та оптимальним за використанням пам’яті виявився сценарій номер 5 з використанням Multiprocessing. Час виконання скоротився у 2 рази — з 17.63 до 9.52 сек.
def scenario_v5_multiprocessing( key_with_data: List[str], max_workers: int = None, **kwargs ) -> List[str]: all_ids_set = set(create_iterator_from_redis_keys()) membership_list = set(key_with_data) & all_ids_set with ProcessPoolExecutor(max_workers=max_workers) as executor: clean_data = list(executor.map(process_tender_data, membership_list)) return clean_data
Виходить, що з найгіршого сценарію з часом виконання в 46 сек. та RAM 1244 МБ, можна прийти до 9 сек. та 94 МБ. За часом це оптимізація в 5 разів, а за RAM — в 13.
Ну і наостанок. Я вирішив перевірити проблему додавання нового елементу в List, але не в кінець, а весь час на
Графік RAM тут.
А ось і результати: використання пам’яті без змін, а от з часом виконання у List все сильно погіршилося — він збільшився майже на 45%.
Ось такий вийшов лонгрід. Можливо, я десь схибив у замірах, та думаю, що загальний напрямок витримав. А якщо сильно схибив — готовий до предметної дискусії, яка дасть змогу розширити свої знання.
16 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів