Когда твой Data Science скрипт работает как улитка
Знакомо чувство, когда запускаешь обработку данных, а прогресс-бар движется так медленно, что успеваешь выпить три чашки кофе? Ты проверяешь логи - все вроде работает. Память в норме. Но время выполнения зашкаливает.
Ты открываешь код. Там вложенные циклы, сложные преобразования, куча Pandas операций. Где именно тормозит? В каком конкретно месте? Обычный print() уже не помогает, а cProfile выдает гору данных, которую нужно часами анализировать.
Вот классическая ошибка: начинаешь оптимизировать не то место. Тратишь два дня на ускорение функции, которая занимает 2% времени выполнения. А настоящий убийца производительности скрывается в трех строчках кода, которые вызываются миллион раз.
Py-Spy: снайпер для медленного кода
Py-Spy - это не просто еще один профайлер. Это инструмент, который работает без модификации кода. Без декораторов. Без импортов. Он просто подключается к запущенному Python процессу и показывает, где именно процессор проводит время.
Сравни с другими инструментами:
| Инструмент | Требует изменения кода | Накладные расходы | Лучше всего для |
|---|---|---|---|
| cProfile | Да | Высокие | Детальный анализ |
| line_profiler | Да | Средние | Построчный анализ |
| memory_profiler | Да | Высокие | Анализ памяти |
| Py-Spy | Нет | Минимальные | Быстрое обнаружение узких мест |
1 Установка и быстрый старт
На 05.02.2026 Py-Spy поддерживает все актуальные версии Python (включая Python 3.13). Устанавливается одной командой:
pip install py-spy
# Или через conda для Data Science окружений
conda install -c conda-forge py-spy
Реальный пример: анализ геоданных, который тормозил 4 часа
Возьмем типичную Data Science задачу - расчет расстояний между точками. Изначальный код выглядел так:
import pandas as pd
import numpy as np
from math import radians, sin, cos, sqrt, atan2
def haversine_distance(lat1, lon1, lat2, lon2):
"""Вычисление расстояния по формуле Haversine"""
R = 6371.0 # Радиус Земли в километрах
lat1_rad = radians(lat1)
lon1_rad = radians(lon1)
lat2_rad = radians(lat2)
lon2_rad = radians(lon2)
dlat = lat2_rad - lat1_rad
dlon = lon2_rad - lon1_rad
a = sin(dlat / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
def calculate_all_distances(df):
"""Плохая реализация: O(n²) сложность"""
distances = []
for i in range(len(df)):
for j in range(len(df)):
if i != j:
dist = haversine_distance(
df.iloc[i]['lat'], df.iloc[i]['lon'],
df.iloc[j]['lat'], df.iloc[j]['lon']
)
distances.append((i, j, dist))
return pd.DataFrame(distances, columns=['point_a', 'point_b', 'distance'])
# Генерация тестовых данных
np.random.seed(42)
df = pd.DataFrame({
'lat': np.random.uniform(-90, 90, 100),
'lon': np.random.uniform(-180, 180, 100)
})
# Этот вызов работал 4+ часа
result = calculate_all_distances(df)
Проблема очевидна для опытного глаза - O(n²) сложность. Но что если код сложнее? Что если там несколько вложенных функций и ты не видишь картину целиком?
2 Запускаем Py-Spy на медленном скрипте
Сохраняем код в файл slow_geo.py и запускаем:
# Запускаем скрипт в фоне
python slow_geo.py &
# Получаем PID процесса
ps aux | grep slow_geo.py
# Запускаем Py-Spy для записи профиля
py-spy record -o profile.svg --pid PID_NUMBER
# Или проще - сразу запускаем с профилированием
py-spy record -o profile.svg -- python slow_geo.py
Через 30 секунд (не нужно ждать 4 часа!) прерываем выполнение Ctrl+C. Получаем файл profile.svg - интерактивную flame graph.
Flame graph - это визуализация стека вызовов. Ширина прямоугольника показывает, сколько времени проводит процессор в этой функции. Самые широкие прямоугольники - главные кандидаты на оптимизацию.
Читаем flame graph как профессионал
Открываем profile.svg в браузере. Видим картину:
- 90% времени уходит в функцию
calculate_all_distances - Внутри нее 85% времени - вызовы
haversine_distance - Каждый вызов
haversine_distanceтратит время на математические операции
Но самое главное - видим масштаб проблемы. Функция вызывается 9900 раз (100×100 минус диагональ). Это и есть O(n²).
3 Режим top: реальное время в реальном времени
Еще один мощный режим - top. Он показывает текущие выполняющиеся функции, как системный top, но для Python:
py-spy top --pid PID_NUMBER
Вывод в реальном времени:
%Own %Total OwnTime TotalTime Function
85.2% 85.2% 102.4s 102.4s haversine_distance
8.7% 93.9% 10.5s 112.9s calculate_all_distances
3.1% 97.0% 3.7s 116.6s <listcomp>
1.2% 98.2% 1.4s 118.0s radians
Цифры говорят сами за себя. 85% времени в одной функции.
Оптимизация: от 4 часов до 2 секунд
Теперь, когда точно знаем проблему, можно оптимизировать. Вот три уровня оптимизации:
Уровень 1: Векторизация с NumPy
def haversine_vectorized(lat1, lon1, lat2, lon2):
"""Векторизованная версия"""
R = 6371.0
lat1_rad = np.radians(lat1)
lon1_rad = np.radians(lon1)
lat2_rad = np.radians(lat2)
lon2_rad = np.radians(lon2)
dlat = lat2_rad - lat1_rad
dlon = lon2_rad - lon1_rad
a = np.sin(dlat/2)**2 + np.cos(lat1_rad) * np.cos(lat2_rad) * np.sin(dlon/2)**2
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
return R * c
def calculate_distances_vectorized(df):
"""Оптимизированная версия"""
n = len(df)
# Создаем матрицы всех пар
lat1 = df['lat'].values[:, np.newaxis]
lon1 = df['lon'].values[:, np.newaxis]
lat2 = df['lat'].values[np.newaxis, :]
lon2 = df['lon'].values[np.newaxis, :]
# Векторизованный расчет
distances = haversine_vectorized(lat1, lon1, lat2, lon2)
# Убираем диагональ (расстояние до себя)
np.fill_diagonal(distances, np.nan)
return distances
Это уже дает ускорение в 100-200 раз. Но можно лучше.
Уровень 2: Используем специализированные библиотеки
# Установка: pip install geopy
from geopy.distance import geodesic
import numpy as np
def calculate_distances_geopy(df):
"""Используем оптимизированную библиотеку"""
from geopy.distance import geodesic
from concurrent.futures import ThreadPoolExecutor
points = list(zip(df['lat'], df['lon']))
n = len(points)
distances = np.zeros((n, n))
def calc_pair(i, j):
if i != j:
return geodesic(points[i], points[j]).km
return 0
# Параллельное выполнение
with ThreadPoolExecutor(max_workers=8) as executor:
futures = []
for i in range(n):
for j in range(n):
futures.append((i, j, executor.submit(calc_pair, i, j)))
for i, j, future in futures:
distances[i, j] = future.result()
return distances
Уровень 3: Переосмысливаем задачу
А нужно ли вообще считать все попарные расстояния? Часто в Data Science достаточно:
- Расстояния до k ближайших соседей
- Расстояния только для точек в определенном радиусе
- Использовать приближенные алгоритмы для больших данных
from sklearn.neighbors import BallTree
import numpy as np
def calculate_knn_distances(df, k=5):
"""Только k ближайших соседей вместо всех попарных"""
# Конвертируем в радианы для BallTree
points = np.radians(df[['lat', 'lon']].values)
# Используем гаверсинусовое расстояние
tree = BallTree(points, metric='haversine')
# Расстояния до k ближайших соседей
distances, indices = tree.query(points, k=k+1) # +1 чтобы исключить саму точку
# Конвертируем радианы в километры
distances = distances * 6371.0
return distances[:, 1:], indices[:, 1:] # Исключаем первую колонку (точка сама с собой)
Итог: было 4 часа, стало 2 секунды. Ускорение в 7200 раз.
Продвинутые фичи Py-Spy для Data Science
Профилирование Jupyter Notebook
Да, Py-Spy работает и с Jupyter. Запускаем в отдельном терминале:
# Находим PID процесса Jupyter
pgrep -f "jupyter.*notebook"
# Профилируем
py-spy record -o notebook_profile.svg --pid PID --duration 30
Многопроцессорные приложения
Для многопроцессных Data Science пайплайнов:
# Профилируем все процессы
py-spy record -o multi.svg --pid PID --subprocesses
# Или только определенный subprocess
py-spy record -o worker.svg --pid WORKER_PID
Интеграция с Docker
Py-Spy отлично работает в контейнерах. Главное - запустить с правильными привилегиями:
# Внутри контейнера
docker exec -it --privileged CONTAINER_ID /bin/bash
pip install py-spy
py-spy record -o /tmp/profile.svg --pid 1 --duration 60
# Или с хоста
py-spy record -o profile.svg --pid $(docker inspect --format '{{.State.Pid}}' CONTAINER_NAME)
Чего не может Py-Spy (и что использовать вместо)
Py-Spy - не серебряная пуля. Вот его ограничения:
- Память - не отслеживает использование памяти. Для этого нужен
memory_profilerилиfilprofiler - GPU - не профилирует GPU операции. Для PyTorch/TensorFlow используй NVIDIA Nsight или PyTorch Profiler
- Детальный анализ строк - не показывает, какая именно строка в функции тормозит. Используй
line_profilerпосле того, как Py-Spy найдет проблемную функцию - I/O операции - плохо показывает блокирующие вызовы. Для асинхронного кода лучше подходит
viztracer
Распространенные ошибки при профилировании
Видел эти ошибки сотни раз:
Ошибка 1: Профилирование в development окружении с другими данными. Профилируй всегда на production-like данных.
Ошибка 2: Слишком короткое время профилирования. Минимум 30 секунд, лучше 2-3 минуты.
Ошибка 3: Игнорирование теплого старта. Первый запуск всегда медленнее из-за компиляции, кэширования и т.д.
Ошибка 4: Оптимизация не того. Сначала измерь, потом оптимизируй. Всегда.
Py-Spy в пайплайне CI/CD
На 05.02.2026 лучшие практики включают автоматическое профилирование:
# .github/workflows/profile.yml
name: Performance Profiling
on:
pull_request:
branches: [ main ]
jobs:
profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
pip install py-spy pandas numpy
pip install -r requirements.txt
- name: Run performance tests
run: |
# Запускаем тест с профилированием
timeout 60s py-spy record -o profile.svg -- python performance_test.py || true
# Анализируем результаты
if [ -f profile.svg ]; then
# Проверяем, нет ли функций, которые занимают >50% времени
# Можно добавить автоматический анализ flame graph
echo "Performance profile generated"
fi
Что дальше после Py-Spy?
Нашел узкое место с Py-Spy? Отлично. Теперь:
- Используй
line_profilerдля детального анализа конкретной функции - Проверь память с
memory_profiler- может быть, проблема в аллокациях - Для числового кода посмотри в сторону Mojo, Nuitka или Cython
- Если проблема в GPU - переходи к NVIDIA Nsight Systems
- Рефактори код, используя принципы чистого ML кода
Помни: Py-Spy - это диагностический инструмент. Он не лечит, он ставит диагноз. А лечение - это уже твоя работа.
Самый важный урок: не гадай. Не предполагай. Не оптимизируй наугад. Запускай Py-Spy, смотри flame graph, находи реальную проблему. Часто это будет не та функция, которую ты подозреваешь.
В Data Science особенно: кажется, что тормозит сложный ML алгоритм, а на деле - банальный pandas merge без индекса. Или преобразование типов в цикле. Или сериализация данных.
Py-Spy покажет правду. Даже если она неудобная.