Фрагменти й копії. Як Python марнує памʼять

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до Python спільноти!

Фрагментація — це велика частина проблеми. Ви живете в місті, де сміття щотижня забирають просто з узбіччя, і ви більше його не бачите, тож у вас немає відчуття, звідки воно береться. Так само як немає розуміння, звідки надходить вода. Через це зникає відчуття відповідальності й підзвітності, а також відчуття власної спроможності впливати на ситуацію.
Севeрн Калліс-Сузукі

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

У попередній статті про памʼять ми зазирнули вглиб того, як вона структурована, заклали підвалини розуміння, що відбувається під капотом, коли ми пишемо Python-код. Тож перш ніж ознайомлюватись з поточною статею, раджу прочитати минулу частину, оскільки далі буде логічне продовження вже викладеного матеріалу.

Частина 1. Анатомія памʼяті в Python. З чого починається оптимізація
Частина 3. Поза межами Python. Шлях до швидкодії, що дихає на C
Частина 4. Поза межами Python. Кешування в Python. Мистецтво памʼятати лише потрібне

Цього разу підемо далі. Маю за мету надати розуміння, як і куди подекуди памʼять витрачається, а точніше марнується, чому не звільняється, коли мала б, і головне — як ми можемо це виправити.

Тут поговоримо про те, які проблеми з памʼяттю в Python ми можемо зустріти у повсякденному написанні коду, розглянемо типові патерни неефективного коду, розберемо мікропомилки, які коштують мегабайтів, зберемо арсенал підходів до оптимізацій, які не потребують переписування архітектури, але дають реальні результати.

Ця стаття не про те, як написати найшвидший у світі код, вона — про усвідомленість, про спосіб мислення, що не дозволить витрачати зайвого.

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

І важлива заувага. У Python є багато різних реалізацій інтерпретаторів: CPython, Jython, IronPython та PyPy, написані відповідно на C, Java, C# та Python. У даній статті буде йти мова лише про CPython-інтерпретатор.

👉 Копіювання в памʼяті
— Поверхневе копіювання
— Глибоке копіювання
— Приховане копіювання
👉 Фрагментація памʼяті
👉 Методи боротьби з копіями та фрагментами
— Що використати замість копій
— Альтернативи звичному
👉 Висновки

Уявіть собі місто, яке стрімко зростає. Щодня зʼявляються нові будинки, квартири, офіси. Щоб пришвидшити процес, архітектори просто копіюють типові проєкти, іноді змінюючи тільки колір стін. Бо так зручно: швидко, зрозуміло, менше помилок, перевірений спосіб та ніяких ризиків. Ніхто не замислюється над тим, скільки реально потрібно площі та матеріалів, головне — аби працювало.

Будівлі зводять там, де є вільний клаптик землі, не думаючи про щільність чи ефективність використання простору. Часто сусідні ділянки стоять пустими, бо новобудову вже не вписати в ті розриви. Землі багато, але вона зайнята дрібними, неоптимально використаними об’єктами.

Спочатку все виглядає нормально: інфраструктура справляється, люди снують, бізнес працює. Але з часом — вузькі вулиці, дублікати кварталів, «мертві зони» і відчуття, що «місця ніде нема», хоча його ніхто наче не займав, стають на заваді оптимальному та ефективному функціонуванню міста. А перебудувати тепер важко — місто живе.

Так часто відбувається з памʼяттю в Python: ми створюємо нові структури, копіюємо, бо так зручніше, або ж і зовсім не замислюємося, що там, під капотом, наш новий рядок коду створив кілька непотрібних копій, розміщуємо обʼєкти без стратегії — і навіть не помічаємо, як простору стає критично мало, а продуктивність падає, хоча ніхто спеціально не «марнував ресурси», і взагалі код не змінювався.

Зручність — сильна річ. Але без розуміння, що під капотом, вона тихо веде до хаосу.

Копіювання в памʼяті

У низькорівневих мовах, як-от C або Rust, існує ідея zero-copy (поговоримо про неї детальніше в наступній частині серії). Це концепція, коли дані передаються, опрацьовуються або перетворюються без фактичного копіювання в памʼяті. У Python таке трапляється вкрай рідко. Навпаки, тут майже кожна операція означає створення нового обʼєкта, тобто копіювання.

Здається, що ми просто «передаємо обʼєкт», додаємо елемент або вирізаємо частину списку — але під капотом Python створює нові обʼєкти, нові посилання, займає нові шмати памʼяті. Усе це має свою ціну — і не лише в мегабайтах, а й у кількості алокацій, фрагментації, і роботі збирача сміття (англ. garbage collector, абрев. GC).

Працюючи з Python-структурами, ми часто вважаємо, що копіюємо обʼєкти — а насправді лише переадресовуємо вказівники, інтуїтивно очікуючи незалежності, а отримуючи звʼязаність. І навпаки — замість бажаного легкого дублювання створюємо важкі копії з тисячами рекурсивних викликів.

У випадку незмінних типів все доволі просто: ми не можемо змінити значення на місці, тож копіювання зазвичай не потрібне. І присвоєння створює нове імʼя для того самого значення, що часто не спричиняє проблем.

Але все інакше, коли ми працюємо зі змінними структурами — наприклад, зі списками, словниками або обʼєктами класів. Якщо на один і той самий обʼєкт посилаються кілька змінних, то зміни, зроблені через одне імʼя, впливають на все, що з ним повʼязано. Такі побічні ефекти можуть бути не очевидними, але дуже небезпечними у великих проєктах.

Саме тут з’являються copy.copy() і copy.deepcopy() - дві функції з модуля copy, що обіцяють клонування, але з різною поведінкою під капотом.

Одна з них — копіює лише «коробку», залишаючи вміст тим самим, інша — запускає копіювання для всього дерева обʼєктів, рекурсивно відтворюючи все до найменшої структури.

Поверхневе копіювання

Спочатку розглянемо поверхневе копіювання за допомогою copy.copy() на прикладі гір України.

from copy import copy

mountains = {
    'Carpathians': {
        'Hoverla': 2061,
        'Pip_Ivan': 2022,
    },
    'Podillia': {
        'Tovtry': 401,
        'Medobory': 440,
    }
}

backup = copy(mountains)

print(backup) 
print(mountains) 
print(backup == mountains) 
print(backup is mountains)

Маємо словник mountains, у якому два ключі — ’Carpathians’ і ’Podillia’. Кожен із них пов’язаний із вкладеним словником, де вказані назви гір і їхні висоти. Тобто, mountains — це композитний об’єкт другого рівня вкладеності.

Функція copy() створює новий словник верхнього рівня, у який копіюються посилання на ті ж самі вкладені словники, що й у mountains. Інакше кажучи, структура дублюється поверхнево, а внутрішні обʼєкти (словники про Карпати й Поділля) — не копіюються, а лише переадресовуються. Тож і mountains, і backup посилаються на одні й ті самі вкладені словники.

Що і бачимо у результатах порівняння на рівність значень (==), отримавши True, тому що обидва словники мають однакові ключі та значення. А от друге порівняння поверне False, бо copy() при створенні backup робить новий словник — інший об’єкт у памʼяті, навіть якщо його вміст збігається з оригіналом.

Отже, отримуємо такі відповіді:

{'Carpathians': {'Hoverla': 2061, 'Pip_Ivan': 2022}, 'Podillia': {'Tovtry': 401, 'Medobory': 440}}
{'Carpathians': {'Hoverla': 2061, 'Pip_Ivan': 2022}, 'Podillia': {'Tovtry': 401, 'Medobory': 440}}
True
False

Тепер додамо ще одні українські гори та подивимося, як зміниться результат:

mountains['Сrimea'] = {
    'Roman_Kosh': 1545,
    'Ai_Petri': 1234,
    'Eklizi_Burun': 1527,
}

print(mountains)
print(backup)
print(backup == mountains)
print(backup is mountains) 

Новий ключ ’Сrimea’ спричинить зміну оригінального словника, а backup залишиться без змін. Це стається тому, що функція copy() створює новий верхній словник, але не копіює вкладені структури, а лише посилається на ті самі об’єкти всередині. Тому наразі і вміст словників також буде різнитися.

{'Carpathians': {'Hoverla': 2061, 'Pip_Ivan': 2022}, 'Podillia': {'Tovtry': 401, 'Medobory': 440}, 'Сrimea': {'Roman_Kosh': 1545, 'Ai_Petri': 1234, 'Eklizi_Burun': 1527}}
{'Carpathians': {'Hoverla': 2061, 'Pip_Ivan': 2022}, 'Podillia': {'Tovtry': 401, 'Medobory': 440}}
False
False

Тож тут вже бачимо обмеження, що дає нам поверхневе копіювання. Продовжимо експерименти:

mountains['Carpathians']['Petros'] = 2020

print(mountains) 
print(backup) 
print(backup is mountains) 
print(backup['Carpathians'] is mountains['Carpathians'])
print(backup['Podillia'] is mountains['Podillia'])

Коли ми додаємо ’Petros’ до вкладеного словника mountains[’Carpathians’], ця зміна відразу з’являється і в backup, попри те, що backup — це окремий обʼєкт. Причина цьому — вкладені словники не копіюються, а залишаються тими самими обʼєктами в памʼяті, на які тепер вказують обидві змінні mountains і backup.

{'Carpathians': {'Hoverla': 2061, 'Pip_Ivan': 2022, 'Petros': 2020}, 'Podillia': {'Tovtry': 401, 'Medobory': 440}, 'Сrimea': {'Roman_Kosh': 1545, 'Ai_Petri': 1234, 'Eklizi_Burun': 1527}}
{'Carpathians': {'Hoverla': 2061, 'Pip_Ivan': 2022, 'Petros': 2020}, 'Podillia': {'Tovtry': 401, 'Medobory': 440}}
False
True
True

Щоб унаочнити експерименти, відобразила на діаграмі структуру виконаних кроків.

Глибоке копіювання

Тепер розглянемо глибоке копіювання. Візьмемо той самий приклад, єдине що замінивши — copy.copy() на copy.deepcopy().

Ключова відмінність стає помітною, коли ми модифікуємо вкладену структуру, додаючи ’Petros’ до ’Carpathians’. У випадку з copy() ця зміна відбивалась і в backup, бо вкладені словники залишалися спільними в памʼяті. Але з deepcopy() цього не відбувається: backup[’Carpathians’] залишається незмінним, оскільки тепер це повністю окремий обʼєкт. Саме в цьому полягає головна перевага глибокої копії — повна незалежність кожного шару структури, навіть якщо вона багаторівнева. На відміну від поверхневої копії, тут жодні зміни в оригіналі не відображаються на копії.

Зробимо висновок, що використання copy() доцільно, коли достатньо скопіювати лише верхній рівень обʼєкта, і ми не плануємо змінювати вкладені структури. Це швидше й економніше. Якщо ж структура глибока, і ми хочемо повністю ізолювати копію від оригіналу, обираймо deepcopy(). Але варто памʼятати, що вона повільніша й витрачає більше памʼяті. Оптимальний вибір залежить від того, наскільки незалежною має бути копія і чи ми змінюватимемо вкладені обʼєкти.

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

initial = [1]
initial.append(initial)

print(initial[1] is initial)

Це створює циклічне посилання: другий елемент списку — це і є сам цей список, відповідно той самий обʼєкт в памʼяті. Тому у результаті виконання коду ми отримаємо відповідь True.

Ця структура є дотепною аналогією до нескінченного ланцюгового дробу, який використовується для представлення золотого перетину (φ):

Утворений список [1, [1, [1, [...]]] нагадує цей ланцюговий дріб — перше значення 1, а потім — знову той самий список, і так далі нескінченно. Цей приклад показує, як структура даних може відображати математичну ідею нескінченної самоподібності.

З такими структурами поверхнева копія стає недієвою — вона лише дублює верхній рівень, не знаючи, що всередині є посилання на той самий обʼєкт. І у випадку рекурсивного або циклічного вмісту це призводить до дивних ефектів або помилок. У таких випадках слід застосовувати глибоку копію, яка враховує циклічні посилання і не ввійде у нескінченну рекурсію.

Приховане копіювання

Що може спричинити копії під час написання коду, окрім безпосередньо свідомого контрольованого методу копіювання за допомогою функцій copy() та deepcopy(). Насправді дуже багато чого.

Слайсінг списків, рядків та байтів. Це класичний приклад «неявного» копіювання, яке може виглядати як нешкідлива операція, але насправді створює новий об’єкт у пам’яті.

initial = [1000, 2000, 3000, 4000]
backup = initial[:2]

print(initial[0] is backup[0])  # True

Коли ми робимо зріз, Python під капотом створює новий список, копіюючи в нього вміст зазначеного діапазону з initial. Це неглибока копія — тобто копіюються лише посилання на ті ж самі обʼєкти всередині списку. Але це копія.

У випадку слайсінгу рядка чи байтів буде створено новий об’єкт, навіть якщо дані збігаються. Причина цьому — незмінність цих типів даних.

Сюди ж відноситься одна з найбільш неочевидних операцій у Python, яка викликає копіювання як ліворуч, так і праворуч, часто непомітно для розробника — вставка у зріз.

a = [1000, 2000, 3000, 4000]
a[1:3] = [5000, 6000]

Python створює копію зрізу a[1:3], що дорівнює [2000, 3000], потім формує новий список, де замінює цей фрагмент елементами з [5000, 6000]. У результаті вихідний список модифікується на місці, але з внутрішнім копіюванням частини даних.

Приведення типу. Функції-конструктори list(), dict(), set() зазвичай створюють новий контейнер і намагаються скопіювати вміст ітерованого об’єкта або іншого контейнера у нову структуру. Це знов-таки поверхнева копія, тобто копіюються тільки самі посилання на обʼєкти, але не обʼєкти рекурсивно.

initial = [1000, 2000, 3000]
backup = list(initial)

print(backup == initial)  # True
print(backup is initial)  # False

initial.append(4)
print(backup)  # [1000, 2000, 3000]

Бачимо з прикладу, що list() створює копію, а backup є новим об’єктом, у якому тепер є ті ж самі елементи (посилання). І при додаванні в initial нового елемента він не зʼявиться в backup, бо це вже інша структура даних.

Але памʼятаймо, що у випадку змінної вкладеної структури зміни в копії відобразяться.

initial = [[1000], [2000], [3000]]
backup = list(initial)

backup[0].append(4000)

print(initial)  # [[1000, 4000], [2000], [3000]]  

Зі словниками та множинами — та сама історія. Такі конструкції зручні, але при великому обʼємі даних або в циклах можуть «мимоволі» створювати безліч копій, збільшуючи витрати памʼяті.

Аналогічно для str(), bytes(), bytearray(), tuple() - всюди створюється новий обʼєкт — тобто копія структури (але з тими ж самими елементами, якщо вони незмінні).

Іноді замість, наприклад, list(x) краще просто працювати з x напряму, або використовувати ітератори чи генератори, якщо повне копіювання не потрібне. Гарний спосіб не марнувати памʼять надарма.

Метод .copy() у списках, словниках та множинах. Це наступний творець копій і насправді він є скороченою формою (англ. shorthand) для функції copy.copy(). Тобто Python під капотом викликає дандер-метод __copy__(), і іменований метод .copy() є просто зручною обгорткою, щоб не писати from copy import copy і не викликати його вручну. Тож все буде працювати ідентично тому, що ми вже розглянули вище.

Розпакування (англ. unpacking). Це ще один копіювальник: операції на кшталт *args чи розпакування списку теж створюють нові обʼєкти.

a = [1000, 2000, 3000]
b = [*a]

print(b)         # [1000, 2000, 3000]
print(b is a)  # False

Сортування. Воно може здаватися операцією «на місці» (англ. in-place), але дуже часто створює нові об’єкти в пам’яті, навіть коли дійсно змінює список «на місці».

sorted(iterable) — завжди створює новий список. Python бере вихідну послідовність, створює копію елементів у новому масиві й виконує алгоритм Timsort поверх цієї копії. Оригінальний список не змінюється, і ми отримуємо абсолютно новий об’єкт.

a = [3000, 1000, 2000]
b = sorted(a)

print(a)  # [3000, 1000, 2000]
print(b)  # [1000, 2000, 3000]

print(a is b)  # False

list.sort() - змінює існуючий список, але теж копіює дані всередині. Python не створює новий список назовні, але всередині відбувається створення допоміжних буферів для сортування, часткове копіювання даних у тимчасові масиви, перестановка вказівників у внутрішньому масиві об’єктів.

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

a = [3000, 1000, 2000]
a.sort()

print(a)  # [1000, 2000, 3000]

Конкатенація. Це ще одне джерело прихованих копій у Python, яке часто недооцінюють. Вона трапляється при використанні операторів +, +=, *, *= для списків, рядків, кортежів, байтів тощо. З погляду користувача — це просте додавання. Але під капотом усе складніше.

a = [1000, 2000, 3000]
b = [4000, 5000]
c = a + b

Python не зʼєднує a і b напряму. Він створює новий список у памʼяті, виділяючи місце для len(a) + len(b) елементів, копіює туди вміст a, потім b, і лише потім повертає посилання на результат.

І це знову-таки поверхнева копія — самі елементи не копіюються, тільки їх посилання. Тобто c[0] is a[0] дасть нам True.

Аналогічно і з іншими типами даних — str, list, tuple, bytes, bytearray.

Отже, у Python багато звичних дій створюють приховані копії об’єктів. Це зручно, але може витрачати памʼять і сповільнювати програму. Щоб писати ефективний код, важливо розуміти та слідкувати за тим, коли саме ми копіюємо і скільки.

Фрагментація памʼяті

Ми вже піднімали тему фрагментації памʼяті в минулій статті, тепер поговоримо про цю проблему детальніше. Принагідно вона є логічним продовженням теми зайвого копіювання в памʼяті, бо безпосередньо є проблемою-результатом цього копіювання. На жаль, фрагментація не проявляється одразу, не падає помилкою, не сигналізує червоним у логах, і навіть іноді не помітна при звичайному профілюванні. Але варто застосунку попрацювати якийсь час, і вона проявить себе: споживання памʼяті зростає, хоча ми нібито нічого нового не створюємо, продуктивність просідає, і те, що працювало оптимально, раптом стає повільнішим.

Що ж відбувається? Фрагментація — це ситуація, коли памʼять усе ще є, але вона розсипана шматками, як мозаїка. Python не може використати її ефективно, бо новим обʼєктам потрібні неперервні блоки памʼяті, а не дрібні фрагменти. Це як мати багато вільного простору в квартирі, але не знайти, де поставити ліжко, бо всі кути вже заставлені стільцями, лампами й коробками.

У CPython памʼять виділяється через систему pymalloc, що оперує аренами, пулами та блоками. І якщо обʼєкт видалено — памʼять звільняється, але повністю звільнити арену можна лише тоді, коли в ній більше нічого немає. Часткове звільнення не допомагає — фрагменти лишаються, і Python не повертає їх назад до операційної системи.

До речі, ми можемо подивитися ітерації того, що Python робить під капотом, як та скільки виділяє памʼяті для обʼєктів.

Напишіть у файлі test.py просту інструкцію:

a = 1000

А тепер запустіть скрипт із ввімкненим дебагом виділення памʼяті в CPython:

PYTHONMALLOC=debug PYTHONMALLOCSTATS=1 python 1_memory/test.py 2>&1 | tee malloc_stats.txt

Ця команда активує детальне логування внутрішнього аллокатора памʼяті Python pymalloc. У результаті вивід міститиме сотні рядків, серед яких побачимо, скільки арен виділено (по 256 КБ кожна), скільки пулів використано (по 4 КБ), яку кількість блоків створено в кожному класі розмірів, та прослідкуємо за загальним розподілом памʼяті по типах обʼєктів. Навіть для такого дуже простого скрипта pymalloc робить багато дій під капотом. Звісно ж, профілювання більших скриптів може дати значущу картину того, що насправді відбувається всередині та значно допоможе в пошуку проблем, як-от фрагментація.

Особливо швидко фрагментація накопичується при роботі з великою кількістю дрібних обʼєктів, які часто створюються і видаляються: список словників, вкладені обʼєкти, мільйони строк, багато числових значень тощо. Кожна така дія — це нові алокації, що після видалення залишають після себе «дірки» в купі (англ. heap).

Як наслідок, маємо проблеми не лише з неоптимальним використанням памʼяті. Усе це порушує локальність у CPU-кеші, тобто ускладнює прогнозування доступу до обʼєктів у памʼяті. У результаті — більше промахів кешу, довші затримки, повільніші ітерації, складніша відладка.

Щоб зрозуміти, чому CPython-інтерпретатор іноді «утримує» сотні мегабайт пам’яті, навіть після того, як ми видалили всі об’єкти, розгляньмо простий, але показовий приклад.

Створимо великий список об’єктів — мільйони словників — і поспостерігаємо, як поводиться споживання пам’яті в різних сценаріях, коли частина цих об’єктів залишається «живою».

Початок скрипта:

iter_num = 2_000_000

initial = []
for i in range(iter_num):
    initial.append(None)

# створюємо 2 млн порожніх словників
for i in range(iter_num):
    initial[i] = {}

Будемо дивитися почергово на 4 варіантах:

sliced = []                                 # [1] без додаткових посилань
sliced = initial[::2]                     # [2] кожен другий
sliced = initial[iter_num // 2:]    # [3] друга половина
sliced = initial[::100]                 # [4] кожен сотий

Завершальні дії:

# видаляємо посилання з initial (sliced зберігає частину об’єктів живими)
for i in range(iter_num):
    initial[i] = None

# знову виділяємо словники для initial
for i in range(iter_num):
    initial[i] = {}

Ідея у тому, що sliced — це зріз, який копіює лише посилання, а не самі словники. Тому, як ми вже зʼясували з розділу вище, об’єкти, на які посилається sliced, не звільняються, навіть якщо в initial ми поставили None.

Погляньмо, як буде відрізнятися RSS (абрев. Resident Set Size — обсяг фізичної пам’яті, яку процес Python реально займає в оперативці в цей момент) і розберемо, чому так відбувається. Запускала таку команду і за результатами профілювання відмалювала діаграму.

export PYTHONMALLOCSTATS="True" && python3 test.py 2>result.txt

У першому випадку з порожнім sliced маємо після першої хвилі створення словників зростаючу RSS приблизно до ~120 MB. Після занулення initial всі об’єкти стають недосяжними, тому багато арен повністю порожніють, відповідно частина пам’яті повертається ОС (скільки саме — залежить від того, чи спорожніли арени повністю). Друга хвиля знову піднімає RSS приблизно до тих же значень. З цього робимо висновок, що повернення пам’яті можливе, якщо арени звільнились повністю.

У випадку слайсінгу кожного другого обʼєкта списку бачимо, що половина словників лишається «живою» через sliced. Як наслідок, RSS піднімається приблизно до 180 MB. У більшості арен лишається щонайменше кілька зайнятих пулів/блоків, тому арени не повертаються ОС. У другій хвилі багато блоків перевикористовуються з уже виділених пулів, тому нове виділення помітно менше. Отже, маємо часткове утримання памʼяті — типовий прояв фрагментації. Арени залишаються зайнятими навіть після видалення більшості об’єктів.

Третій випадок — друга половина списку. Типова (але не гарантована!) поведінка алокатора: друга половина створених об’єктів часто потрапляє у «пізніші» арени. Коли ми знімаємо посилання з першої половини, частина арен дійсно вивільняється і повертається ОС, тому бачимо, що RSS тимчасово падає. Але друга хвиля знову виділяє багато пам’яті, відповідно загальна крива виглядає так, що ми спочатку звільняємо частину зайнятої памʼяті, а потім витрати знову ростуть. На графіку видно падіння з приблизно 120 MB до 70 MB, після чого під час другої хвилі створення об’єктів обсяг знову зростає майже до 180 MB. Тут важливо зазначити, що розкладка по аренах — це деталь реалізації і не жорстка гарантія, тому такі результати можна назвати типовою тенденцією, а не правилом.

І останнє — кожен сотий. Список sliced тримає дуже мало об’єктів (≈1%), але вони розкидані по багатьох аренах, тож (майже) жодна арена не порожніє повністю. Як наслідок маємо те, що після занулення initial RSS залишається майже на рівні піка (умовні ~120 MB). Друга хвиля майже не потребує нових алокацій — переважно перевикористання. Тож є лише невелика кількість «довговічних» дрібних об’єктів, розсіяних по аренах, що може утримувати в рази більше пам’яті, ніж вони реально займають.

Важливо зазначити: фрагментація — це не витік памʼяті (англ. memory leak). Під час витоку памʼяті об’єкти стають недосяжними, але зайнята ними памʼять не повертається системі. При фрагментації ж навпаки: непотрібні об’єкти успішно звільняються, але через «дірки» різного розміру всередині виділених арен Python не може ефективно використати всю доступну памʼять. Тобто якщо в аренах залишилися інші живі блоки, арени лишаються закріпленими за процесом, і пам’ять повернеться ОС лише коли арени спорожніють повністю або процес завершиться.

Методи боротьби з копіями та фрагментами

Проблеми зрозумілі — копіювання, як наслідок — фрагментація. Що ж з цим робити? Розглянемо методи, які пропонує нам Python, а от у наступній статті звернемося до бібліотек, що реалізовані під капотом на С, що й забезпечує швидкодію.

Що використати замість копій

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

Генератори. Це один з найефективніших способів уникнути зайвих копій та зменшити використання памʼяті, що завжди під рукою і є частиною Python Core.

Використання «лінивих» обʼєктів, що створюють елементи по одному, на вимогу, не зберігаючи нічого зайвого, суттєво знижує марнування памʼяті та допомагає у боротьбі з надмірним копіюванням.

lst = [i for i in range(1_000_000)]
gen = (i for i in range(1_000_000))

У першому випадку Python одразу виконує обчислення для кожного x, створює список і зберігає його у памʼяті. У другому варіанті створюється генератор. Він нічого не обчислює наперед. Лише тоді, коли ми почнемо ітерацію, генератор поверне значення, по одному, кожного разу виконуючи __next__(). Його розмір у памʼяті — незалежно від кількості значень — залишається мізерним.

Погляньмо на результати з уже відомої з першої статті функції sys.getsizeof().

print(sys.getsizeof(gen))
print(sys.getsizeof(lst))

Я отримала результати 192 байти для генератора та 8448728 байти для списку. Генератор з мільйоном елементів займає лише ~200 байтів, це метадані та стан, а от список набагато важчий, як бачимо, і це реальні значення в памʼяті.

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

Вони збережуть чимало памʼяті, позбавляючи нас від постійних копій. Це одна з найефективніших стратегій для оптимального використання памʼяті в Python. Вони не просто «зручний інструмент», а ключовий елемент для уникнення копій, зменшення фрагментації і покращення продуктивності при роботі з великими або стрімінговими даними.

Слабкі посилання (англ. weak references). У Python це механізм, який дозволяє посилатися на об’єкт, не впливаючи на його підрахунок посилань. Інакше кажучи, не заважати збирачу сміття (англ. garbage collector) знищити об’єкт, коли на нього більше не залишається сильних посилань.

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

Тож як це працює? Коли єдиним посиланням на об’єкт залишається weakref, об’єкт буде знищений, і weakref стане «мертвою», тобто поверне None при спробі отримати об’єкт.

import weakref

class Mountain:
    def __init__(self, name):
        self.name = name

hoverla = Mountain('Hoverla')
ref = weakref.ref(hoverla)

print(ref)               
print(ref())             
print(ref().name)        

del hoverla
print(ref())     

У цьому прикладі ми створили клас Mountain, створили екземпляр hoverla і слабке посилання на нього. Поки існує звичайна змінна hoverla, ref() повертає справжній об’єкт. Але щойно ми видаляємо hoverla, підрахунок посилань доходить до нуля, і збирач сміття видаляє об’єкт. Після цього ref() повертає None. У результаті отримуємо:

<weakref at 0x103013380; to 'Mountain' at 0x1242079e0>
<__main__.Mountain object at 0x1242079e0>
Hoverla
None

Python також надає готову структуру WeakValueDictionary. Це словник, у якому значення зберігаються як слабкі посилання. Коли об’єкт зникає, він автоматично видаляється зі словника за таким же принципом.

Але є але. Не можна створювати слабкі посилання на вбудовані типи як int, str, tuple, бо вони не підтримують слабкі посилання (повинні мати __weakref__).

Тож це інструмент для керування памʼяттю без штучного подовження життя обʼєктів. Слабкі посилання не копіюють і не утримують дані, але надають можливість взаємодії з обʼєктом, допоки він існує.

Це особливо корисно у випадках, коли не треба утримувати об’єкти в пам’яті, якщо вони більше ніде не використовуються, як-от кеші, чи при графоподібних об’єктах для уникнення циклічних посилань між вузлами або ж при патерні Спостерігач (англ. observer pattern), де об’єкти можуть підписуватись на події, але автоматично «відписуватись», коли вони зникають.

Слоти. Це інструмент для оптимізації памʼяті у класах, коли ми заздалегідь знаємо всі поля, які будуть у наших обʼєктів. Замість того щоб використовувати звичайний динамічний словник (__dict__) для зберігання атрибутів, Python може створити фіксовану структуру, що займає менше пам’яті та пришвидшує доступ до атрибутів.

class City:
    def __init__(self, name, population):
        self.name = name
        self.population = population

c = City(‘Kharkiv’, 1_300_000)
print(c.__dict__) 

При звичному використанні отримаємо таку відповідь, бо кожен обʼєкт City матиме окремий словник __dict__, у якому зберігаються всі атрибути:

{'name': 'Kharkiv', 'population': 1300000}

Використовуючи __slots__, ми кажемо інтерпретатору: «Мій клас має лише такі поля».

class City:
    __slots__ = ('name', 'population')
….

У цьому випадку Python не створює __dict__ для кожного екземпляра, замість цього використовується фіксований індексований масив, аналог C-структури, що значно зменшує споживання памʼяті та прискорює доступ.

Альтернативи звичному

Продовжуючи розглядати методи оптимізації споживання памʼяті та зменшення її фрагментації, розгляну доволі часту у повсякденні структуру як список словників. Почнемо шукати способи зберегти памʼять за допомогою вбудованих Python-інструментів, а в наступній статті вийдемо за межі Python-структур та порівняємо споживання памʼяті в усіх експериментальних випадках.

Отже, список словників. Звична, зручна у використанні, інтуїтивна, гнучка структура, а отже, здається безпечною та універсальною. Але це лише на перший погляд.

Щоразу, коли ми додаємо в список черговий словник з даними, Python під капотом створює нові обʼєкти, копіює ключі, виділяє шматки памʼяті, зберігає метадані, управляє хеш-таблицями, і робить ще цілу низку дій, про які більшість розробників навіть не здогадується або ж просто не замислюється. Усе це працює автоматично — але ця «магія» має свою ціну. І коли список налічує вже не десятки, а тисячі чи мільйони словників — ця ціна стрімко зростає.

Щоб дослідити це, слід зазирнути глибше: на рівень купи (англ. heap), стеків, байткоду і обʼєктної моделі CPython.

З минулої статті знаємо, що у CPython список реалізований як масив вказівників на об’єкти. Це динамічно розширюваний масив (PyObject **items), який зберігає тільки адреси інших об’єктів, а не їхні значення. Коли ми створюємо список словників, насправді під капотом ми створюємо:

  • один об’єкт списку (одна структура PyListObject);
  • N обʼєктів словників (PyDictObject);
  • по кілька обʼєктів на кожен словник: ключі (PyUnicodeObject), значення (PyLongObject, PyFloatObject, тощо).

Таким чином список словників — це не щільна табличка з рядками та стовпцями, а ієрархія обʼєктів, розкиданих по памʼяті. У кожному рядку ми повторюємо ті самі ключі, ті самі метадані, ті самі службові структури. Для прикладу: якщо в кожному словнику є поле «country»: «Ukraine», то «country» — це окрема строка-обʼєкт, яка існує незалежно для кожного словника, навіть якщо значення не змінюється. Таке дублювання — це типовий приклад надлишкової алокації, яка росте лінійно разом із кількістю записів.

Окрім надмірного використання памʼяті така структура негативно впливає на продуктивність. Усі ці обʼєкти — словники, ключі, значення — алоковані в різних місцях купи, тож коли Python ітерує через список, він постійно звертається до памʼяті у непередбачуваних місцях. Це призводить до частих промахів кешу процесора (англ. cache misses), що сповільнює доступ до даних навіть у простих операціях.

Щоб перейти до конкретних цифр, давайте перевіримо, скільки памʼяті насправді займають однакові дані, представлені у різних структурах. Для цього створюємо 1 мільйон записів, у кожному з яких є три поля: id, name та age.

Заміряємо реальне використання памʼяті за допомогою бібліотеки pympler, яка дозволяє точно порахувати кількість байтів, що займає структура в оперативній памʼяті, враховуючи всі вкладені обʼєкти.

Почнемо зі вже згаданого списку словників.

from pympler import asizeof

N = 1_000_000

data_dict = [{'id': i, 'name': f'name{i}', 'age': i % 100} for i in range(N)]
print('List[dict]:', round(asizeof.asizeof(data_dict) / 1024**2, 2), 'MB')

Отримуємо в результаті:

List[dict]: 267.45 MB

Отже, що відбувається у кожному словнику? Перша проблема — це ключі («id», «name», «age»), вони є окремими строковими обʼєктами, які дублюються в кожному рядку разом з власним хешем, енкодингом і службовими байтами.

Друга проблема — значення. Навіть прості цілі числа — це окремі PyLongObject, які містять метаінформацію, вказівники, системну обгортку, і все це множиться на кожен рядок.

І третє — сам словник. У Python це доволі складна структура з хеш-таблицею, вказівниками на ключі й значення.

Це означає, що навіть якщо ми повторюємо однакову структуру мільйон разів, Python не перевикористовує ключі — він створює нові строки, нові пари, нові метадані, нові таблички. Обʼєктна модель CPython не оптимізована для таких масових повторів — і памʼять витрачається шалено швидко. У результаті створюються мільйони дрібних обʼєктів, розкиданих по купі, що спричиняє фрагментацію, високі накладні витрати і погану кеш-локальність (здатність даних розташовуватись у памʼяті так, щоби вони часто потрапляли в кеш-памʼять процесора, що пришвидшує доступ до них), що спричиняє те, що доступ до кожного поля — це кілька непрогнозованих стрибків по памʼяті, з великою ймовірністю промаху кешу (ситуація, коли CPU не знаходить потрібні дані в кеші й змушений читати їх із повільнішої оперативної памʼяті).

Проблеми зрозумілі. Чи допоможе така альтернатива, як іменовані кортежі (англ. namedtuple)?

from pympler import asizeof
from collections import namedtuple

N = 1_000_000

Person = namedtuple('PersonNT', ['id', 'name', 'age'])
data = [Person(i, f'name{i}', i % 100) for i in range(N)]
print('List[namedtuple]:', round(asizeof.asizeof(data) / 1024**2, 2), 'MB')

Маємо такі значення:

List[namedtuple]: 153.01 MB

У цьому випадку кожен запис має фіксований набір полів, через що ми отримуємо виграш — поля зберігаються щільно, словник на кожен запис не створюється, і Python знає заздалегідь, які атрибути є в кожному обʼєкті. Чому це так? CPython оптимізує namedtuple, зберігаючи поля як __slots__, що дозволяє уникнути алокацій словника на кожен екземпляр. Чому слоти оптимізовують споживання памʼяті, ми вже обговорили вище.

По-друге, — структура запису компактна — немає хеш-таблиць, тільки значення у порівнянні з минулим варіантом.

Ці всі умови зменшують накладні витрати, але типи значень все одно залишаються динамічними, строки дублюються, і фрагментація купи все ще присутня. Можна краще!

from pympler import asizeof
from dataclasses import dataclass

N = 1_000_000

@dataclass(slots=True)
class PersonDC:
    id: int
    name: str
    age: int

data_dc = [PersonDC(i, f'name{i}', i % 100) for i in range(N)]
print('List[dataclass(slots=True)]:', round(asizeof.asizeof(data_dc) / 1024**2, 2), 'MB')

Подивимося результат:

List[dataclass(slots=True)]: 145.38 MB

У випадку з датакласами ми отримуємо структуру, яка за поведінкою ближча до класу, але з ефективністю, подібною до namedtuple. Основна оптимізація досягається завдяки використанню спеціального механізму __slots__, який забороняє створення атрибутивного словника (дандер-метод __dict__) для кожного екземпляра. Замість того щоб кожен обʼєкт мав власну хеш-таблицю для зберігання атрибутів, Python створює фіксований набір полів, доступ до яких реалізований через індекси в попередньо згенерованій структурі. Тож знову слоти стають у нагоді при намірі зберегти памʼять.

Це означає, що в кожному обʼєкті не буде зайвих метаданих — тільки самі значення. Усі поля відомі наперед, займають визначене місце в памʼяті та не потребують хешування при доступі, на відміну від словників. Завдяки цьому структура виймається з кешу CPU швидше, а обсяг зайнятої памʼяті — суттєво менший. Крім того, dataclass дозволяє типізувати поля, що не лише покращує контроль на рівні коду, але й відкриває можливості для подальших оптимізацій (наприклад, у JIT або Cython, про що поговоримо у наступній частині).

Попри ці переваги, значення залишаються динамічними Python-обʼєктами: строки, числа тощо — усе ще створюється окремо в купі. Тому певне дублювання і фрагментація памʼяті зберігаються.

У наступній статті продовжимо експерименти, порівнюючи результати і намагаючись зменшити кількість копій та фрагментацію памʼяті.

Висновки

У Python, як і в житті, зручність часто має ціну. Ми копіюємо, дублюємо, множимо — не замислюючись, скільки простору це займає і які проблеми спричиняє згодом. Памʼять не завжди скаржиться одразу, але з часом накопичується шум: зайві речі, зайві дії, зайві тіні від колишніх рішень.

Ця стаття — не про заборону копій, а про уважність. Про свідомість. Про відповідальний вибір між «швидко» і «легко». Python дозволяє багато, але не змушує — і в цьому його сила. Писати відповідально — значить бачити більше, ніж просто робочий код.

Хто ще не встиг ознайомитися з першою частиною — ласкаво прошу.

Частина 1. Анатомія памʼяті в Python. З чого починається оптимізація
Частина 3. Поза межами Python. Шлях до швидкодії, що дихає на C
Частина 4. Поза межами Python. Кешування в Python. Мистецтво памʼятати лише потрібне

Дякую за увагу! Не прощаємось.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось20
До обраногоВ обраному18
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
export PYTHONMALLOCSTATS="True" && python3 test.py 2>result.txt

Тут зайвий конвеєр. Можна простіше:

PYTHONMALLOCSTATS="True" python3 test.py 2>result.txt

Наступна частина опублікована — dou.ua/forums/topic/57275

Згоден в цілому, проте є декілька технічних зауважень.

Вираховуючи витрати памʼяти, не можна орієнтуватись тільки на RSS. Якщо ви запустите той же самий тест на одному процесі декілька разів і поміряєте витрати, наприклад, після 1, 2 і 100 ідентичних запусків, побачите, що числа після 2 і після 100 будуть майже однакові. Це тому, що вся ця памʼять, яка для ОС не може бути вивільнена через ситуації типу 1 живий обʼєкт на сторінку VM, для внутрішнього malloc вільна і може бути використана знову. Для довготриваючих процесів це важливіше, ніж миттєве повернення в ОС.

А ось що було б цікаво для таких процесів — це поміряти дані з роботи з іншими реалізаціями malloc. Є такі, що тримають різні арени для обʼєктів різного розміру, і тому краще поєднують сусідні. Це найпростіша ідея, там ще є. Швидкість в них також може бути краща.

Генератори — це добре у деяких випадках, проте вони можуть і значно сповільнювати код.

Взагалі, багато чого в Python оптимізується контр-інтуїтивно. Ось наприклад sortedcontainers був би значно повільнішим у версії C, ніж стандартні підходи на деревах (AVL, RB, B), а саме для кода на Python він найбистріший серед альтернатив. А все тому, що, на рівні процесора компактне зберігання списку навіть при тому, що він весь з посилань, дружить з кешуванням. Ну і саме робота з великими списками гарно оптимізована в самому інтерпретаторі.

Рецепти зі статті корисні в багатьох випадках, але не завжди. У кожному конкретному випадку треба міряти.

Але з даними поправками, як на мене, гарний посібник для подальшого вивчення вглиб.

Наступна частина опублікована — dou.ua/forums/topic/57063

Не читал, но одобряю
если описанная проблема аналогична проблемам в других языках/фреймворках, то знать как с ней бороться — это очень полезный инструмент в наборе разработчика
особенно учитывая что питон любят использовать для «анализа больших данным тм» ©, то правильное использование памяти весьма и весьма необходимо
Добавил в закладки, как только появится время вдумчиво прочитать — обязательно это сделаю.

Для аналізу великих даних на пайтоні юзаються ліби, написані на С (pandas,numpy) , а пайтон лише викликає потрібні методи. Щось аналізувати на чистому пайтоні — це самогубство)

Про це і є наступна стаття)

Щось аналізувати на чистому пайтоні — це самогубство)

«анализ больших данных» просто как расхожий пример
факапы с памятью касаются все случаев.

особенно учитывая что питон любят использовать для «анализа больших данным тм» ©, то правильное использование памяти весьма и весьма необходимо

Тут правило досить просте, та про нього ніхто не написав у статті: «використовуй правильні інструменти», тобто бібліотеки на кшталт NumPy, Pandas, Dask, Vaex, PyArrow тощо, де все реалізовано на Сі та операції виконуються на нативному коді без оверхеду Python об’єктів. У самому Python це зробити неможливо, ок, можна якось зменшити оверхед, але втекти від того, що кожне значення буде обгорнуто в PyObject, ніяк. Та й не треба по великому рахунку. Кожен раз коли таким приходиться займатися, це червоний прапорець: зайшли кудись не туди. Так, можна оптимізувати нехай у кілька раз, але у порівнянні з нативним кодом це все одно буде прірва, тобто можливості оптимізації дуже обмежені.

Python ... оптимізація

у цьому аспекті цікавим булоб (в яких-небудь наступних циклах публікацій) побачити порівняння (наскільки то можливо) нп Cython і Mojo — як і взагалі про їх використання

Дякую за статтю, дуже цікава

Для всіх читачів, кого зацікавить продовження: ще дві частини досліджень про памʼять вийдуть за кілька днів. Щоб не пропустити, слідкуйте за оновленнями DOU у LinkedIn, Telegram, Facebook та інших платформах. Або просто поверніться до цієї статті згодом — тут з’являться посилання на наступні частини, щойно вони будуть опубліковані.
Дякую за увагу до статті!

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