Аналізуємо використання RAM та CPU в Python

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до 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.

Отримані результати:

  1. List — 8.59 MB
  2. Deque — 8.62 MB
  3. Dict — 11.57 MB
  4. Set — 11.91 MB

Найбільша технічна конфа ТУТ!🤌

Виходить, що List та Deque — найменші накладні витрати для зберігання простих даних (+/- 25%). А от Dict та Set — майже 75%! На такій кількості IDs це не смертельно, а от на великій кількості даних — зайві витрати як за ресурсами заліза, так і за фінансовими.

Але це не основне, що мене цікавило перед початком експериментів. Основне — які ресурси витрачаються, щоб створити ці колекції. А саме — CPU, RAM та час виконання.

Для більшої наочності я вирішив будувати графік використання CPU та RAM. Для цього написав пару декораторів, котрі вимірюють ці показники (cpu та ram) за допомогою бібліотеки psutil і потім виводять графіки (graphs).

Як виконувалися вимірювання та будувався графік:

  1. Створення кожної колекції запускалося окремо, щоб не створювати можливих накладань показників.
  2. Дані зберігалися в Redis.
  3. Кожну колекцію створював 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, але не в кінець, а весь час на 0-й індекс. Змагатися з List буде Deque (який для цього і створювався). В різних статтях весь час пишуть, що це проблема для List, але я ніде не зустрічав якихось конкретних цифр або порівняння з чимось. От і вирішив порівняти. Тест буде такий саме, як перший, але тільки з однією змінною — нові елементи додаються на початок.

Графік RAM тут.

А ось і результати: використання пам’яті без змін, а от з часом виконання у List все сильно погіршилося — він збільшився майже на 45%.

Ось такий вийшов лонгрід. Можливо, я десь схибив у замірах, та думаю, що загальний напрямок витримав. А якщо сильно схибив — готовий до предметної дискусії, яка дасть змогу розширити свої знання.

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Хвилинка реклами — найповніший список питань та відповідей для інтервю на пайтон розробника
dou.ua/forums/topic/51528

Так а що стосовно array.array?

array.array його не тестив.

UUID 128 біт. Найбільший розмір який можна створити ціле у array — 64 біт.

$ python3
Python 3.13.1 (main, Dec  3 2024, 17:59:52) [Clang 16.0.0 (clang-1600.0.26.4)] on darwin
>>> from array import array
>>> import uuid
>>> a = array("Q")
>>> a.append(uuid.uuid4().int)
Traceback (most recent call last):
  File "<python-input-3>", line 1, in <module>
    a.append(uuid.uuid4().int)
    ~~~~~~~~^^^^^^^^^^^^^^^^^^
OverflowError: int too big to convert
Тут теж нічого дивного не відбулося — Dict та Set відпрацювали за О(1) та виконалися майже миттєво з самими мінімальними затратами ресурсів. Різниця в часі виконання, порівняно з List та Deque, просто величезна — в 44 та 55 разів.

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

Якби дека — це один з небагатьох потоко-безпечних контейнерів в Пайтоні. Там буквально на кожен аппенд/поп аквайриться/релізиться лок. Авжеж, ця структура даних буде більше інших давити на ресурси. Тому примітка взагалі ні про що. Не уявляю, як взагалі можна «вибрати» деку замість, скажемо, сета.

Дуже гарна стаття, але відсутність висновку відчутно зменшує її корисність для читача.

На днях доповню статтю висновком.
Дякую за коментар)

не завжди висновки треба, достатньо буває контенту

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

Цікавить/не-цікавить вирішують по темі чи тайтлу топіка, далі по ходу контенту можна скіпнути і не читати далі, або читати якщо зацікавило. Оцінювати ж корисність по висновкам? — мабуть для аі індексації і зручно.

p.s.

Висновок.. про що саме йдеться у кожній частині статті

висновок по кожній частині і потім сумарний висновок по висновкам — то навіть занадто в квадраті буде

до речі по стилю графіки дуже схожі на mrtg/rrd

Я про це не знав. Зараз загуглив — треба буде почитати.
oss.oetiker.ch/...​ool/gallery/index.en.html

Там нічого особливого чи ще такогось, єдине що в rrd дата автоматично агрегуються (тобто нп старіші за сутки — залишає щось усереднене по якомусь періоду, за місяць ще, і т.д.).
Плюс тойщо за розміром бази даних (файлів) особливо і дивитися не треба — не переповниться. Мінус як наслідок плюса — старої хісторі нема.
Це те що я пам’ятаю щодо графіків з rrd, який часто для моніторингу зручний (з mrtg зв’язкою, чи ще з іншою).

Знову стаття без висновків? Або висновок що варто перечитати «Грокаєм Алгоритми» ?

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

А передбачення були які? Порівняння з теорією?

Підписатись на коментарі