Поза межами Python. Шлях до швидкодії, що дихає на C
Ступінь повільності прямо пропорційний силі памʼяті. Ступінь швидкості — прямо пропорційний силі забування.
Мілан Кундера
Привіт усім шукачам істини! Коли можливості класичного Python вже вичерпані, а задача все ще занадто повільна, або памʼяті недостатньо — саме тоді варто озирнутися на те, що лежить поза межами звичайного інтерпретатора. Світ оптимізації в Python — це не лише «пишу швидкий код», а й зміна підходу, способу мислення і навіть залучення інших мов та концепцій.
Про структуру памʼяті та основи роботи з нею в Python ми поговорили в першій статті серії, в другій подивилися, як та чому створюються копії, та що вони можуть спричинити, яким чином це можна виправити можливостями Python. Тож перш за все рекомендую прочитати минулі частини, бо інформація в поточній буде логічно нашаровуватися на вже викладену попередньо. Остання частина серії буде присвячена кешуванню — окремій концепції, безпосередньо повʼязаній з використання памʼяті.
Частина 1. Анатомія памʼяті в Python. З чого починається оптимізація
Частина 2. Фрагменти й копії. Як Python марнує памʼять
Частина 4. Поза межами Python. Кешування в Python. Мистецтво памʼятати лише потрібне
Уявімо, що в нас є сто яблук, і потрібно кожне порізати на шматочки. Якщо б ми діяли звично в Python-стилі, тобто класичним циклом, то ми б брали одне яблуко, різали б його, потім друге, потім третє — сто повторів однієї операції, послідовно.
А якщо ми б мали машину для нарізання яблук, яка бере одразу всю партію, кладе яблука на конвеєр і ріже всі одночасно, ми б не були змушені керувати кожним ножем окремо і витрачати стільки часу і сили. У цій статті й поговоримо про такі машини.
👉 Векторизація
Векторний підхід
Альтернативи NumPy
👉 Компілюємо Python на C
JIT-компіляція
Cython
👉 Zero-copy концепція
👉 Альтернативи звичному
👉 Висновки
Тож тут подивимось, як можна переосмислити обчислення. Розглянемо, де Python працює повільно, як можна уникнути цього без радикального переписування, що таке векторизація, zero-copy, JIT та чому Cython — не просто заміна, а місток між зручністю Python і продуктивністю C. Ми дослідимо альтернативи звичному NumPy, компіляцію в байткод, оптимізацію доступу до памʼяті та підходи, які вже сьогодні дають Python змогу працювати там, де раніше домінували лише
Векторизація
У Python ми звикли до використання циклів — і для більшості задач цього дійсно достатньо. Але коли мова йде про обробку великих обсягів числових або табличних даних, цикли швидко стають вузьким місцем. І ми упираємося в питання памʼяті та швидкодії.
Існує поняття векторизації — способу обчислень одразу над усією структурою даних, без використання явних циклів. Замість того щоб по черзі обробляти кожен елемент у списку, ми виконуємо одну векторну операцію над усією послідовністю.
У Python це найчастіше реалізується через бібліотеки на кшталт NumPy, Pandas, JAX, Polars, які всередині використовують C, Rust або Fortran. Це дозволяє досягти продуктивності на рівні системного коду, але використовуючи зручність Python. Тож під капотом ми виходимо за межі Python, використовуючи підходи та бібліотеки з С-подібних мов.
Векторний підхід
Отже, у векторному підході ми запитуємо: «Що я хочу зробити з усією структурою даних?», а не: «Що я хочу зробити з кожним елементом?». І ця зміна мислення дозволяє писати коротший і чистіший код, уникати помилок циклів, покладатися на оптимізовані низькорівневі реалізації, зменшити використання памʼяті та легше перейти до розподілених чи паралельних обчислень.
Якщо зазирнути вглиб, при використанні векторного підходу на сцену виходять SIMD‑інструкції (англ. Single Instruction, Multiple Data). Це означає, що одна машинна команда виконується одночасно над кількома значеннями.

Сучасні процесори мають спеціальні векторні регістри (наприклад,
Для розміру вектора 5 процесор зробить дві операції SIMD: одну на перших 4 елементах, другу — на залишку. Якщо елементів 12 — буде 3 операції, замість 12. Якщо 40 — буде 10. І що більше даних — то більший приріст продуктивності.

Тож можна сказати, що векторизація = масова обробка, яка вбудована в сам рівень заліза. Python через NumPy або Pandas просто вміє цим скористатись.
Уявімо, що в нас є масив з 10 мільйонів чисел, і ми хочемо порахувати, наприклад, вже згадані x * 2 + 5 для кожного елемента.
Код з використанням стандартного циклу:
import time
import psutil
import os
def memory_usage_mb():
process = psutil.Process(os.getpid())
return process.memory_info().rss / 1024 / 1024 # MB
size = 10_000_000
a = list(range(size))
start_mem = memory_usage_mb()
start = time.time()
b = []
for x in a:
b.append(x * 2 + 5)
end = time.time()
end_mem = memory_usage_mb()
print(f'Час: {end - start:.4f} c')
print(f'Використана пам’ять: {end_mem - start_mem:.2f} MB')
У результаті отримаємо:
Час: 0.6136 c Використана пам’ять: 315.23 MB
Тепер застосуємо Numpy на основі векторизації:
import numpy as np
import time
import psutil
import os
def memory_usage_mb():
process = psutil.Process(os.getpid())
return process.memory_info().rss / 1024 / 1024 # MB
size = 10_000_000
a = np.arange(size)
start_mem = memory_usage_mb()
start = time.time()
b = a * 2 + 5
end = time.time()
end_mem = memory_usage_mb()
print(f'Час: {end - start:.4f} c')
print(f'Використана пам’ять: {end_mem - start_mem:.2f} MB')
Отримані результати:
Час: 0.0146 c Використана пам’ять: 77.28 MB
Результати чітко демонструють перевагу векторного підходу. У варіанті з циклом обчислення займають понад 0.6 секунди і споживають понад 300 МБ памʼяті — кожна операція створює новий обʼєкт у Python, що потребує окремої алокації. Натомість NumPy виконує ті самі обчислення у 40+ разів швидше й майже в 4 рази економніше по памʼяті. Це стає можливим завдяки роботі на рівні C та використанню векторних інструкцій, які дозволяють обробляти блоки даних у памʼяті без створення мільйонів окремих Python-обʼєктів.
Погляньмо детальніше на різницю між тим, як зберігаються дані у звичайному списку Python та у масиві NumPy, і чому векторизація працює оптимальніше по памʼяті.

Елементи звичайного Python-списку зберігаються у різних ділянках пам’яті. Список фактично містить посилання на об’єкти, які розкидані по пам’яті в довільному порядку. Щоразу, коли програма звертається до елемента, їй потрібно слідувати цьому посиланню й діставати дані звідти. Це означає, що треба більше пам’яті на зберігання, а також це повільніший доступ, бо процесору складніше ефективно кешувати такі «розкидані» дані.
А от масиви NumPy зберігають усі елементи послідовно в одному безперервному блоці пам’яті. Замість посилань є лише одна адреса початку блоку, а елементи розташовані поруч один з одним. Це дозволяє читати їх значно швидше — процесор може завантажувати великі шматки даних за один раз, а SIMD-інструкції легко обробляють їх векторно. Така організація пам’яті не лише пришвидшує обчислення, а й зменшує накладні витрати на зберігання.
Альтернативи NumPy
Тепер поговоримо про цікавий інструмент, який дозволяє працювати з даними масштабів «занадто великих для Pandas», не змінюючи звичних звичок. Йдеться про Dask — фреймворк, який поєднує простоту NumPy/Pandas з можливістю паралельної обробки, масштабування та розподілення навантаження.
По факту це Python-бібліотека для паралельних обчислень. Вона дозволяє працювати з масивами (dask.array) і таблицями (dask.dataframe) так само, як з NumPy і Pandas, але з важливою відмінністю: Dask розбиває дані на частини і виконує обчислення паралельно, навіть якщо дані не влазять у памʼять.
Суть швидкодії тут криється в лінивих обчисленнях (англ. lazy evaluations) та графі цих обчислень. Коли ми викликаємо метод у Dask — нічого не відбувається одразу, Dask лише будує спрямований ациклічний граф обчислень (англ. Directed Acyclic Graph, абрев. DAG), що є послідовністю завдань, які треба виконати.
Тільки коли ми викликаємо .compute(), Dask аналізує граф, розпаралелює його на ядра і виконує всі задачі. І що головне — ефективно. Це доволі схоже на Spark або Prefect, але локально і без складної інфраструктури.
Треба зауважити, що Dask DataFrame — не одне й те саме, що й Pandas DataFrame. Хоча API дуже схожий, під капотом dask.dataframe розкривається в набір з багатьох pandas.DataFrame, які обробляються по частинах. Що мається на увазі? Наприклад, якщо у нас є 100 ГБ CSV-файл, Dask не зчитує його повністю — він ділить його на шматки (наприклад, по 100 МБ) і обробляє кожен окремо, часто паралельно.
Ми вже обговорили, як NumPy дозволяє замінити цикли на векторні операції, так от, якщо по-простому, Dask Array — це NumPy на масиві масивів. Тобто він дозволяє писати векторизований код, який виконується над частинами даних паралельно, не тримаючи весь масив у памʼяті.
Подивимося використання на прикладі. Згенеруймо великий CSV-файл з даними.
import pandas as pd
import numpy as np
num_rows = 100_000_000
countries = ('USA', 'Canada', 'Germany', 'France', 'Ukraine', 'Japan')
df = pd.DataFrame({
'country': np.random.choice(countries, size=num_rows),
'sales': np.random.uniform(100, 1000, size=num_rows).round(2),
'year': np.random.randint(2010, 2024, size=num_rows),
'product_id': np.random.randint(1000, 5000, size=num_rows)
})
df.to_csv('large_file.csv', index=False)
І порівняймо ефективність обробки такого великого CSV-файлу за допомогою двох бібліотек: Pandas і Dask. Обидві бібліотеки виконують однакову задачу — читають файл, групують дані за певною колонкою та обчислюють середнє значення по іншій колонці. Така операція є типовою для аналізу даних і дозволяє наочно побачити різницю в продуктивності та використанні пам’яті.
import pandas as pd
import dask.dataframe as dd
import time
from typing import Callable
from memory_profiler import memory_usage
from pandas import Series
FILENAME = 'large_file.csv'
def pandas_task() -> Series:
df = pd.read_csv(FILENAME)
result = df.groupby('country')['sales'].mean()
return result
def dask_task() -> Series:
df = dd.read_csv(FILENAME)
result = df.groupby('country')['sales'].mean().compute()
return result
def measure(task_func: Callable, label: str):
start_time = time.time()
mem_usage = memory_usage(task_func, interval=0.1, max_usage=True)
end_time = time.time()
print(f'{label}:')
print(f'Час: {end_time - start_time:.3f} с')
print(f'Використано памʼяті: {mem_usage:.2f} MB')
measure(pandas_task, "Pandas")
measure(dask_task, "Dask")
Результати, що я отримала:
Pandas: Час: 17.335 с Використано памʼяті: 7225.30 MB Dask: Час: 9.560 с Використано памʼяті: 4708.16 MB
Проаналізуймо. Pandas читає весь файл у пам’ять як один монолітний об’єкт. Це дозволяє швидко оперувати даними, але вимагає значного обсягу оперативної пам’яті. У нашому випадку обробка даних зайняла 17.3 секунди, а пік споживання пам’яті досяг 7.2 ГБ. Це може стати критичним обмеженням для ще більших файлів або для систем із меншою кількістю доступної RAM.
На відміну від Pandas, Dask працює за принципом розбиття даних на менші чанки (англ. partitions), обробляючи їх окремо та поступово. Це дозволяє обробляти дані, які не поміщаються в пам’ять, і задіяти всі доступні CPU ядра. У нашому прикладі Dask виконав ту ж саму операцію вдвічі швидше — 9.5 секунди, ще й при цьому використавши значно менше пам’яті — 4.7 ГБ.
Хоча абсолютне скорочення часу в секундах може здатись незначним, для великих обсягів даних та в реальних сценаріях обробки мільйонів рядків це дає серйозний приріст у масштабованості та стабільності. Особливо важливою є менша потреба в памʼяті — це дозволяє запускати обробку на слабших машинах або в хмарних середовищах із жорсткими обмеженнями по ресурсах.
Чи буде так завжди? Я б сказала — ні, якщо візьмемо менші обʼєми даних — побачимо зворотний результат. У більшості випадків Pandas впорається з усім, що нам треба, але коли потрібно дуже швидко обробити дуже багато — нам до Dask.
Наприклад, у питанні обмеження памʼяті Dask допомагає, бо читає з диска частинами, не тримає все в RAM, в паралелізмі він стане у нагоді, бо навантаження розбивається на задачі, що запускаються на кількох ядрах, а при масштабуванні можна запустити обробку на кластері (локально, у Kubernetes, на AWS чи GCP).
І що цікаво, Dask не переписує Pandas/NumPy — він оркеструє їхню роботу, але при цьому уникає копій даних де можливо (англ. zero-copy — розглянемо нижче), оптимізує порядок виконання, перевикористовує результати і дозволяє векторизувати код на рівні фреймворку.
Компілюємо Python на C
JIT-компіляція
Відомий недолік Python у його повільності виконання у порівнянні з мовами на кшталт C або Java. І частково це пов’язано з тим, що Python — інтерпретована мова: її код виконується інтерпретатором рядок за рядком, без попередньої трансляції у машинний код.
Бібліотека Numba пропонує вирішення цієї проблеми, а саме застосування компіляції під час виконання (англ. Just-In-Time, абрев. JIT), яка дозволяє переводити частини Python-коду у швидкий машинний код «на льоту».
Однією з найпростіших і найефективніших форм застосування JIT-компіляції є декоратор @njit (скорочено від @jit(nopython=True)).
Спочатку байт-код нижчого рівня надходить у фронтенд Numba, де інтерпретатор будує проміжне представлення програми — Numba IR. На цьому етапі виконується аналіз структури коду, після чого запускається механізм виведення типів, і програма набуває строго типізованої форми — Typed Numba IR. Далі починається бекендова частина: це проміжне представлення перетворюється у LLVM IR — універсальний низькорівневий формат, зручний для оптимізацій та генерації швидкого коду. Потім етап lowering формує інструкції, готові до компіляції, а LLVM JIT Compiler трансформує їх у машинний код, який виконується напряму процесором.
Саме цей процес відбувається автоматично при першому виклику функції, яку декоровано за допомогою Numba. Вона аналізується, компілюється у високопродуктивний машинний код за допомогою LLVM, а під час наступних викликів виконується вже без інтерпретації Python, напряму на рівні CPU. Завдяки цьому функції з інтенсивними обчисленнями — зокрема ті, що працюють з циклами, умовами та масивами — отримують значне пришвидшення без зміни початкового Python-коду.

Варто зазначити, що Numba намагається визначити типи змінних самостійно при першому запуску функції. Наприклад, якщо ми передали np.ndarray типу float64, Numba оптимізує функцію під цей тип. Це схоже на C: якщо один раз вказали тип — далі код максимально оптимізований під нього. Якщо потім викликати ту саму функцію з масивом типу int32, буде створено ще одну JIT-компільовану версію, типізовану вже під новий тип. Це називається function specialization.
Погляньмо на приклад використання Numba. Буде використано NumPy, бо його масиви (ndarray) мають фіксований тип і розмір, що дуже зручно для компіляції в машинний код, а також Numba має вбудовану підтримку NumPy, що допомагає оптимізувати роботу з масивами напряму (векторні операції, цикли, індексацію тощо).
import numpy as np
import time
from memory_profiler import memory_usage
def pairwise_distances(points: np.ndarray) -> np.ndarray:
n = len(points)
result = np.zeros((n, n))
for i in range(n):
for j in range(n):
dx = points[i][0] - points[j][0]
dy = points[i][1] - points[j][1]
result[i, j] = (dx**2 + dy**2) ** 0.5
return result
N = 2000
points = np.random.rand(N, 2)
start_mem = memory_usage()[0]
start = time.time()
dists = pairwise_distances(points)
end = time.time()
end_mem = memory_usage()[0]
print(f'Час: {end - start:.4f} с')
print(f'Використана памʼять: {end_mem - start_mem:.2f} MB')
Отже, наша задача — обчислити матрицю попарних евклідових відстаней. У нас є масив N точок у 2D-просторі, і ми хочемо обчислити матрицю розміром N×N, де кожен елемент [i][j] є відстанню між точками i та j. Це дуже витратна операція через подвійний цикл.
Поглянемо на результати без використання Numba:
Час: 2.8571 с Використана памʼять: 30.55 MB
Тепер єдине, що змінимо, це додамо декоратор на функцію:
from numba import njit @njit def pairwise_distances(points: np.ndarray) -> np.ndarray: ...
І поглянемо на результати, запустивши функцію pairwise_distances двічі з замірами показників часу та памʼяті:
Час: 0.3665 с Використана памʼять: 73.62 MB Час: 0.0075 с Використана памʼять: 30.53 MB
Отже, бачимо, що наївна реалізація в чистому Python (навіть з NumPy) справляється, але займає значний час — майже 3 секунди на масив із великою кількістю точок.
Використавши Numba, досягаємо радикального прискорення: перший запуск після компіляції скоротив час до 0.37 с, другий запуск (вже скомпільований) — до 0.0075 с.
Що ж до використаної памʼяті — тут цікава динаміка. Numba зазвичай не зменшує споживання памʼяті, оскільки працює з тими ж самими масивами в памʼяті. Але перший запуск потребував більше (~73 МБ) — це плата за JIT-компіляцію — створення та кешування машинного коду. Наступні виклики повернулись до рівня початкового використання (~30 МБ), тобто додаткові витрати памʼяті тимчасові й повʼязані лише з першою компіляцією. Таким чином можна сказати, що ми залишилися на тому ж використанні памʼяті, але значно прискорили виконання, що точно є непоганим результатом і для точкових CPU-навантажених завдань — чудовим вибором.
Тож можна підсумувати, що Numba — непоганий інструмент для прискорення числових обчислень без переписування логіки. Але важливим обмеженням є те, що Numba працює найкраще з «простим» кодом: числові масиви, цикли, умовні оператори. Якщо у функції використовуються динамічні структури, як-от списки Python, словники, нестандартні об’єкти, — компіляція може бути неможливою або менш ефективною.
Ще один інструмент, про який варто згадати, JAX. Він поєднує в собі силу NumPy, векторизації, JIT-компіляції, що працює через прискорену лінійну алгебру (англ. Accelerated Linear Algebra, абрев. XLA) та автоматичне диференціювання. JAX перетворює функції Python у функціональний граф, який компілюється через XLA, щоб уникнути інтерпретації Python, а також використовувати SIMD-інструкції, ефективне розміщення в пам’яті та паралелізм на рівні ядра процесора.
Погляньмо на той самий приклад з матрицю розміром N×N, що вже розглянули попередньо, порівнюючи результати для NumPy та Numba, але реалізований за допомогою JAX. Тож додамо такий код до вже існуючого.
from jax import jit import jax.numpy as jnp @jit def pairwise_distances_jax(points: jnp.ndarray) -> jnp.ndarray: diff = points[:, None, :] - points[None, :, :] # [N, N, 2] dist = jnp.sqrt(jnp.sum(diff**2, axis=-1)) # [N, N] return dist
І ще треба перетворити масив на тип JAX, тому допишемо ще один рядок коду після його створення:
… points = np.random.rand(N, 2) points_jax = jnp.array(points) …
Запустимо також двічі, бо JAX, як і Numba, використовує JIT-компіляцію: перший запуск — це компіляція + виконання, другий — лише виконання скомпільованого коду. Тому перший раз завжди повільніший.
Я отримала такі результати:
Час: 0.1597 с Використана памʼять: 19.89 MB Час: 0.0019 с Використана памʼять: 8.16 MB
Тож поєднання векторного підходу та С-подібної збірки коду в JAX не лише дозволяє значно прискорити числові обчислення, а й демонструє високу ефективність зі споживання памʼяті, особливо в сценаріях із багаторазовим викликом функцій. Тому якщо потрібно швидко та дешево на великих числових обʼємах — беремо JAX.
Cython
Тепер трохи про інструмент, що поєднує зручність Python зі швидкодією C — Cython. Його головна ідея полягає в тому, щоб дозволити програмісту писати код у звичному синтаксисі Python (із поступовим додаванням типізації), який під капотом компілюється у
Після компіляції за допомогою Cython функції стають
Ще однією перевагою Cython є контроль над памʼяттю. Завдяки явній типізації змінних та використанню
Цікаво, що на відміну від JAX чи Numba, Cython не виконує JIT-компіляцію — його код компілюється наперед (англ. Ahead-of-Time, абрев. AOT) і зберігається у вигляді розширення для подальшого використання.
Давайте створимо файл fibonacci.pyx, де напишемо найстандартнішу реалізацію підрахунку чисел Фібоначчі.
def fib_cython(int n): cdef int a = 0 cdef int b = 1 cdef int i for i in range(n): a, b = b, a + b return a
Щоб зібрати це, скомпілюємо за допомогою такого коду:
from setuptools import setup from Cython.Build import cythonize setup( ext_modules=cythonize(‘fibonacci.pyx’, language_level=3), )
Використаймо команду python setup.py build_ext —inplace. Та подивимось результат:
from fibonacci_py import fib_py
import time
N = 10_000_000
start = time.time()
fib_py(N)
print('Python час:', time.time() - start)
Результат 0.003242 с. виконання. Результат запуску такого ж коду, але на Python я навіть не дочекалася, Cython буде завжди значно швидше звичайного Python. Швидкодія досягається за рахунок того, що Cython транслює Python-код у
Відповідно зникає необхідність у динамічній перевірці типів на кожному кроці, локальні змінні працюють як звичайні
І завдяки тому, що Cython працює з
Zero-copy концепція
Уяви, що твій друг хоче прочитати сторінку з твоєї книги. Ти можеш переписати цю сторінку на окремий аркуш і віддати йому, тобто скопіювати вміст. А можеш просто вказати пальцем точне місце в книзі: «Ось тут, на сторінці 145», нічого не дублюючи.
У памʼяті компʼютера працює той самий принцип: не обовʼязково створювати нову структуру даних, якщо можна створити лише посилання на її частину. Цей підхід має назву zero-copy — «нульове копіювання».
За допомогою чого це можливо? Концепція Buffer Protocol, що дозволяє роботу з памʼяттю напряму, без дублювання, що критично для продуктивності.
Уявімо, що маємо великий масив байтів — наприклад, зображення, аудіо або просто числові дані. Якщо кожна бібліотека, яка з ними працює, створює свою копію — памʼять швидко закінчиться. Але якщо бібліотеки використовують одну й ту ж памʼять, ми отримуємо гнучку, швидку та ефективну обробку.
Саме цей підхід і забезпечує buffer protocol: він дозволяє одному обʼєкту (споживачу) бачити внутрішню памʼять іншого обʼєкта (постачальника) як єдиний масив байтів, не створюючи копій. До цього мають доступ bytes, bytearray, array.array, numpy.ndarray, mmap, memoryview, PyTorch tensors, PIL, Pandas — усе, що працює ефективно з даними.
На практиці взаємодія з buffer protocol зазвичай відбувається через обʼєкт memoryview. Він дає уніфікований доступ до байтового представлення обʼєкта без створення копії. Наприклад, якщо ми хочемо змінити перший байт у bytearray — достатньо зробити це через memoryview, і зміни відбудуться напряму в оригінальному обʼєкті.
Погляньмо, як це працює.
msg = bytearray(b'Oops! I broke prod.')
print('Memoryview. До:', msg, id(msg))
mv = memoryview(msg)
mv[14:18] = b'test'
print('Memoryview. Після:', msg, id(msg))
msg = bytearray(b'Oops! I broke prod.')
print('Python. До:', msg, id(msg))
msg = msg.replace(b'prod', b'test')
print('Python. Після:', msg, id(msg))
Результат:
Memoryview. До: bytearray(b'Oops! I broke prod.') 4583451888 Memoryview. Після: bytearray(b'Oops! I broke test.') 4583451888 Python. До: bytearray(b'Oops! I broke prod.') 4583451504 Python. Після: bytearray(b'Oops! I broke test.') 4583451440
Тут memoryview не створює новий обʼєкт — він просто «дивиться» у вже існуючі в памʼяті дані.
Редагування відбувається напряму в існуючому об’єкті, що підтверджується незмінним id — адреси в памʼяті, навідміну від звичайного використання в Python. Натомість при звичному змінюванні байтстроки через конкатенацію створюється новий об’єкт у памʼяті, id змінюється — відбувається копія. Це і є наочний приклад zero-copy.
І звісно ж, швидкодію краще видно на великій кількості даних, погляньмо:
from timeit import timeit
import matplotlib.pyplot as plt
sizes = [10**i for i in range(1, 10)]
byte_times = []
memview_times = []
def slice_bytes(data: bytes):
_ = data[100:-100] # операція зрізу
def slice_memview(data: bytes):
_ = memoryview(data)[100:-100] # те саме, але zero-copy
for size in sizes:
data = b'x' * size
byte_times.append(timeit(lambda: slice_bytes(data), number=10))
memview_times.append(timeit(lambda: slice_memview(data), number=10))
plt.plot(sizes, byte_times, label='bytes')
plt.plot(sizes, memview_times, label='memoryview')
plt.xscale('log')
plt.yscale('log')
plt.xlabel('Size of data (log scale)')
plt.ylabel('Time for slicing (log scale)')
plt.legend()
plt.title('Zero-copy slicing: bytes vs memoryview')
plt.show()
У цьому експерименті порівнюється швидкість виконання операції зрізу для bytes та memoryview. Різниця між ними полягає в тому, що bytes при кожному зрізі створює новий об’єкт у пам’яті, фактично копіюючи дані, тоді як memoryview не копіює нічого — він просто створює «вікно» в існуючому буфері.

На малих обсягах даних різниця майже непомітна, але зі зростанням розміру даних крива часу для bytes стрімко зростає, оскільки вартість копіювання стає значною. Водночас крива для memoryview залишається майже рівною, бо операція slicing не залежить від розміру даних. Це і є суть zero-copy підходу — робота з великими обсягами пам’яті без зайвих дублювань, що суттєво підвищує ефективність, як і бачимо по результатам.
Ще одним цікавим інструментом, заснованим на концепції zero-copy, є mmap. Він є механізмом відображення файлу або іншого блоку даних у пам’ять. Цей функціонал дозволяє працювати з файлами як з масивами байтів, але без їх копіювання в оперативну пам’ять, саме тут і підтримується концепція zero-copy: дані не зчитуються повністю в RAM, а мапляться в адресний простір процесу.
Коли ми викликаємо mmap у Python, під капотом відбувається кілька важливих речей.
Перше — операційна система виділяє віртуальну памʼять та відображає в неї файл. Це реалізується через низькорівневі системні виклики: mmap() у Linux або CreateFileMapping() у Windows. Тобто файл ніби «зʼєднується» з певним діапазоном віртуальної памʼяті, доступним процесу.
Сам файл при цьому не копіюється в оперативну памʼять одразу. Тож наступний крок — дані підвантажуються сторінково (англ. page-wise) лише тоді, коли програма звертається до певної частини цього діапазону. Це дозволяє ефективно працювати навіть із гігантськими файлами — памʼять витрачається лише на те, що дійсно використовується.
І останнє — Python-обʼєкт mmap реалізує буферний протокол, що дозволяє передавати його напряму в інші бібліотеки без копій: memoryview, struct, NumPy, Pandas, pyarrow тощо. Це означає, що багато інструментів можуть читати і змінювати ці дані напряму, без дублювання в памʼяті — саме тому mmap вважається одним із класичних zero-copy підходів.
import mmap
with open('example.txt', 'wb') as f:
f.write(b'AAAAAAAAAA')
with open('example.txt', 'r+b') as f:
mm = mmap.mmap(f.fileno(), 0)
print('До:', mm[:])
mm[0] = ord('B')
print('Після mmap змін:', mm[:])
mm.close()
with open('example.txt', 'rb') as f:
print('Контент файла на диску:', f.read())
Погляньмо, чи справді відбувається відображення файлу у памʼять із можливістю змін без копіювання. У прикладі створюємо файл з десятьма байтами «A». Далі ми відкриваємо його в режимі читання-запису і створюємо відображення всього файлу у віртуальну памʼять процесу за допомогою mmap. Немає жодного копіювання в окремий буфер — mmap працює з файлом напряму через буферний протокол.
Зміна mm[0] = ord(’B’) одразу оновлює вміст у памʼяті, яка при закритті (mm.close()) автоматично синхронізується з диском. Прочитавши файл вже після змін, бачимо підтвердження, що «A» було замінено на «B».
До: b'AAAAAAAAAA' Після mmap змін: b'BAAAAAAAAA' Вміст файла на диску: b'BAAAAAAAAA'
Отже, mmap буде вдалим вибором, коли потрібно швидко змінювати великі файли без перевантаження оперативки, а також у випадках довільного доступу до великого масиву байтів. І звісно ж загалом у проєктах, де важлива низька затримка доступу до файлу (наприклад, редагування бінарних логів, баз даних, слайсів відео тощо) цей інструмент також може стати в нагоді.
Альтернативи звичному
Розглядаючи методи оптимізації споживання памʼяті та зменшення часу виконання програми, продовжимо досліджувати доволі часту у повсякденні структуру — список словників. У минулій частині ми вже подивилися на способи зберегти памʼять за допомогою вбудованих Python-інструментів, в цій — вийдемо за межі Python-структур та порівняємо споживання памʼяті в усіх експериментальних випадках.
Звісно ж, для початку візьмемо pandas, він точно першим спадає на думку, коли говоримо про оптимізації на рівні С. Порівняно зі вже розглянутими інструментами pandas.DataFrame є векторизованим зберігання та має мінімум накладних витрат, як ми вже дослідили вище. Погляньмо, які дасть результати у порівнянні з отриманими 267.45 MБ для списку словників з минулої частини.
import pandas as pd
N = 1_000_000
df = pd.DataFrame({
'id': range(N),
'name': [f'name{i}' for i in range(N)],
'age': [i % 100 for i in range(N)],
})
print('Pandas DataFrame:', round(df.memory_usage(deep=True).sum() / 1024**2, 2), 'MB')
Значення, що отримуємо:
Pandas DataFrame: 71.42 MB
Pandas — це зовсім інша філософія. Замість масиву обʼєктів вона використовує колонкову модель зберігання. Тобто id, age — це щільні numpy-масиви (типу int64 або float32), які живуть у суміжних блоках памʼяті. Це забезпечує як щільність, так і ефективність доступу, бо процесор завантажує відразу цілі cache line-и з даними, без потреби стрибати по вказівниках. Тут name — обʼєктний стовпець, але все одно з оптимізацією на унікальність, а метадані створюються один раз на стовпець, а не на кожен запис.
Таке зберігання має дві переваги. По-перше, памʼять витрачається значно ефективніше, бо всі значення одного типу зберігаються разом, а по-друге, покращується процесорна локальність, тобто немає випадкових стрибків по купі.
І навіть незважаючи на те, що DataFrame містить службову інформацію (індекси, dtype, назви колонок), сумарне використання памʼяті — в рази нижче, ніж у списку словників, з якого ми починали.
Наступна альтернатива — pyarrow, подивимося, чи можна ще краще.
import pyarrow as pa
arrow_table = pa.table({
'id': pa.array(range(N), type=pa.int64()),
'name': pa.array([f'name{i}' for i in range(N)], type=pa.string()),
'age': pa.array([i % 100 for i in range(N)], type=pa.int64()),
})
print('PyArrow Table:', round(arrow_table.nbytes / 1024**2, 2), 'MB')
Результат:
PyArrow Table: 28.5 MB
У випадку з arrow таблицею ми маємо справу не просто з альтернативою структурам Python, а з низькорівневим, колонковим форматом зберігання даних у памʼяті, розробленим спеціально для аналітичних задач. Apache Arrow використовує векторизовану модель, де кожен стовпець даних зберігається як один щільний масив фіксованого типу — аналогічно до numpy, але з підтримкою складніших типів, пропущених значень і zero-copy доступу.
Коли ми створюємо arrow таблицю, всі поля (id, name, age) існують незалежно один від одного, кожен з яких — це буфер у памʼяті, вирівняний, щільно упакований і повністю однорідний. Для числових полів (наприклад, id, age) використовуються чисті int64 масиви без обгорток у PyObject. Це дозволяє зберігати десятки мільйонів значень з мінімальним обсягом памʼяті, без фрагментації і з чудовою кеш-локальністю.
Строкові поля (name) зберігаються у форматі, подібному до двох масивів: один — це послідовний буфер байтів, інший — масив офсетів, що вказують, де починається і закінчується кожна строка. Це дозволяє уникнути дублювання обʼєктів, як у list[dict], і зберігати навіть великі обʼєми строкової інформації дуже компактно.
Оскільки Arrow має заздалегідь відому схему, не потрібно зберігати жодних метаданих для кожного запису. Немає словників, немає атрибутів, немає хеш-таблиць. Усе зберігається компактно, типізовано, і готове до обробки — навіть на рівні мови C або для передачі між процесами, без копіювання (англ. zero-copy).
У порівнянні з list[dict] це структура, яка прибирає весь Python overhead і зводить обсяг памʼяті до математичного мінімуму, близького до сирих байтів. Це і є причина, чому ця альтернатива дає найнижче споживання памʼяті серед усіх розглянутих варіантів. Це не просто ефективна альтернатива — це інша парадигма роботи з даними.
Тож за результатами бачимо, наскільки сильно вибір структури даних впливає на ефективність використання памʼяті. А отже, структури, зручні для читання, не завжди зручні для обчислень, і якщо даних багато — краще обирати оптимальнішу модель.
Висновки
Попри свою простоту та зручність, Python часто не є найшвидшим чи найефективнішим інструментом для задач, що вимагають високої продуктивності. Але замість того, щоб повністю відмовлятись від нього, ми можемо розширити його можливості, точково використовуючи інструменти та концепції як-от векторизація, zero-copy та buffer protocol і навіть компіляції на С. Усе це доводить, що Python — це не лише про зручність, це платформа, яку можна гнучко адаптувати, розширити й оптимізувати, якщо знати як. І коли ми впираємось у межі, настає вибір: або прийняти обмеження, або вийти за них.
Ця стаття — про сміливість вийти. Про те, як замість того, щоб втікати до іншої мови, можна говорити з памʼяттю напряму, користуючись низькорівневими інструментами. Це про інженерію, що не жертвує зручністю, а трансформує її у щось значно глибше — ефективність, що не втрачає краси.
На цьому все, а наступна стаття буде присвячена кешуванню, зустрінемося там!
Частина 1. Анатомія памʼяті в Python. З чого починається оптимізація
Частина 2. Фрагменти й копії. Як Python марнує памʼять
Частина 4. Поза межами Python. Кешування в Python. Мистецтво памʼятати лише потрібне
Дякую за увагу! Не прощаємось.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

18 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів