Оптимизация и параллельные вычисления

Поиск места, где программа теряет время, и ускорение расчёта на Python: профилирование, приёмы языка, память, Numba, Cython, NumPy, потоки, процессы и видеокарта

Вячеслав Федоров

Лекция 9

Разработка и применение ПО

в физических исследованиях

100x

Содержание

От замера времени до компиляции и параллельных вычислений

01

Причины медленности

02

Измерение

03

Приёмы языка

04

Память

05

Компиляция

06

NumPy и BLAS

07

Потоки, процессы и GPU

Цель лекции

Найти место, где программа теряет время, и ускорить именно его

Профилировщик показывает, на что уходит время. Далее выбирается самый дешёвый способ ускорения: другой алгоритм, приём языка, NumPy, компиляция горячего цикла или параллельные вычисления. Каждый шаг подтверждается замером и тестом: ускоренный неверный расчёт остаётся неверным.

Порядок

ускорения

01

Измерение

профилировщик показывает горячее место

02

Алгоритм и структура данных

O(n²) → O(n log n), список → множество

03

Приёмы языка

встроенные функции, локальные имена, идиомы

04

NumPy

цикл уходит в C и BLAS

05

Numba или Cython

компиляция горячего цикла в машинный код

06

Потоки, процессы и GPU

все ядра процессора и видеокарта

Важно

Сначала правильно, потом быстро: каждая правка проверяется тестом и замером на тех же данных.

Для физика

Порядок выигрыша на стенде: приёмы языка — в несколько раз, NumPy и компиляция — в десятки и сотни раз, процессы — не больше числа ядер.

Пояснение

Шаги идут по возрастанию цены: дальше вниз — больше зависимостей и сложнее код.

Причины медленности

Динамическая типизация, изменяемость, устройство интерпретатора CPython

01

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Динамическая

типизация

def add(a, b):
    return a + b
 
add(1, 2)          # 3
add(1.5, 2)        # 3.5
add('ab', 'c')     # 'abc'
add([1], [2])      # [1, 2]

PythonC
тип значенияизвестен при выполненииизвестен компилятору
числообъект в куче, int — 28 байт8 байт в регистре
a + bвыбор операции по типамодна инструкция

Пояснение

Интерпретатор проверяет типы на каждой операции; заранее вычисляются лишь выражения из констант, остальной код исполняется буквально.

Изменяемость

всего

import builtins
 
len = lambda obj: 'mock!'
len('abc')         # 'mock!'
len = builtins.len
 
def f(a, b): return a + b
def g(a, b): return a * b
f.__code__ = g.__code__
f(1, 2)            # 2

Во время выполнения подменяются встроенные имена, тело функции, атрибуты классов. Интерпретатор не вправе считать, что len — это len, а range — это range.

for i in range(len(a)):
    if i == 0:         # на каждом шаге
        a[i] = 1
    else:
        a[i] = i

Пояснение

Проверку, которую компилятор C вынес бы из цикла, переносит только программист: 13,1 → 9,8 мкс.

Интерпретатор

CPython

>>> dis.dis('a + b')
  0     0 RESUME        0
  1     2 LOAD_NAME     0 (a)
        4 LOAD_NAME     1 (b)
        6 BINARY_OP     0 (+)
       10 RETURN_VALUE

Компилятор переводит код в байт-код, а цикл интерпретатора выполняет инструкции по одной: каждая — ветвь кода на C с проверкой типов и счётчиками ссылок.

ВерсияЧто изменилось
3.11Faster CPython: в среднем в 1,25 раза быстрее 3.10
3.13экспериментальный JIT и отдельная сборка без GIL
PyPyдругая реализация языка с JIT-компилятором

Важно

Производительность не была главной целью CPython, а совместимость с C API ограничивает изменения внутреннего устройства.

Демонстрация:

цена интерпретатора

01

Сумма миллиона чисел циклом for

02

Встроенная sum: тот же счёт на C

03

NumPy: a.sum() над массивом целых

04

Размер одного int и списка из миллиона ссылок

Что наблюдается

Цикл — 12,7 мс, sum — 2,5 мс, NumPy — 0,23 мс: та же сумма в 55 раз быстрее без цикла интерпретатора.

root@lab — запись со стенда

Измерение

Момент оптимизации, timeit, cProfile, line_profiler, py-spy

02

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Момент

оптимизации

«О мелкой эффективности следует забыть в 97 % случаев: преждевременная оптимизация — корень всех зол. Но нельзя упускать возможности в тех критических 3 %»

— Д. Кнут, Structured Programming with go to Statements, 1974

Три правила оптимизации

01

Не делать

02

Делать позже

03

Делать оптимально

Пояснение

О производительности думают с самого начала, выбирая алгоритм и структуры данных; переписывают ради скорости — после замера.

Порядок

оптимизации

01

Профилирование

горячее место показывает профилировщик, а не интуиция

02

Сохранение корректности

тесты до оптимизации: быстрый неверный расчёт остаётся неверным

03

Замер после правки

выигрыш подтверждается на тех же данных

S = 1 / ((1 − p) + p / s)

Доля pУскорение sИтог S
1 %10 раз1,01 раза
50 %10 раз1,8 раза
90 %10 раз5,3 раза
90 %бесконечно10 раз

Пояснение

Закон Амдала: ускорение части программы ограничено долей её времени.

timeit:

время фрагмента

$ python3 -m timeit -s 'setup' 'stmt'
500000 loops, best of 5: 527 nsec per loop
 
>>> import timeit
>>> timeit.timeit('f(s)', setup, number=10000)
0.0052                    # сумма запусков
>>> timeit.repeat('f(s)', setup, number=10000)
[0.0053, 0.0052, ...]     # пять прогонов

01

number и repeat

число запусков в прогоне и число прогонов; берётся минимум

02

Условия замера

сборщик мусора отключён, часы — time.perf_counter

03

IPython

%timeit печатает среднее и отклонение по 7 прогонам

Важно

timeit.timeit возвращает сумму всех запусков, а не время одного.

Демонстрация:

замер timeit

01

Две функции разворота строки в rev.py

02

Цикл со сложением строк: 527 нс

03

Срез s[::-1]: 42 нс

04

Ключи -n и -r: число запусков и прогонов

Что наблюдается

Срез быстрее цикла в 12 раз; timeit сам подбирает число запусков и печатает лучшее из пяти прогонов.

root@lab — запись со стенда

Профилировщики

ИнструментЧто показываетКак запустить
cProfile + pstatsвремя и число вызовов функцийpython3 -m cProfile -o prof.out f.py
SnakeVizпрофиль cProfile диаграммойsnakeviz prof.out
line_profilerвремя каждой строки функции@profile и kernprof -l -v f.py
py-spyвыборки стека работающей программыpy-spy record --pid N
tracemallocвыделения памяти по строкамtracemalloc.start()

Пояснение

cProfile перехватывает каждый вызов и замедляет программу; py-spy опрашивает стек снаружи и почти не влияет на неё.

Для физика

Расчёт, идущий третьи сутки, профилируют py-spy: без перезапуска и правки кода.

Демонстрация:

профиль cProfile

01

matrix.py: 16 умножений матриц 64×64

02

cProfile с записью профиля в prof.out

03

pstats: пять функций с наибольшим временем

04

matrix_product — 0,295 с из 0,308 с

Что наблюдается

96 % времени уходит в matrix_product; генерация случайных матриц занимает проценты.

root@lab — запись со стенда

Демонстрация:

построчный профиль

01

@profile над matrix_product

02

kernprof -l -v: время каждой строки

03

Умножение со сложением — 60,7 %

04

Внутренний цикл for — 38,7 %

Что наблюдается

Почти всё время — две строки внутреннего цикла, выполненные 4 млн раз; профилирование замедлило счёт вчетверо.

root@lab — запись со стенда

Демонстрация:

py-spy без перезапуска

01

beam.py: долгий расчёт в фоне

02

py-spy dump: текущий стек процесса

03

py-spy record: три секунды выборок

04

Остановка расчёта

Что наблюдается

Без перезапуска и правки кода видно, что процесс занят space_charge, строка 11; за 3 с снято 299 выборок.

root@lab — запись со стенда

Приёмы языка

Встроенные функции, структуры данных, идиомы и проверка замером

03

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Уровни

оптимизации

УровеньЧто меняетсяПотенциал
архитектуракакие данные, в каком объёме и где хранятсянаибольший
алгоритмы и структуры данныхO(n²) → O(n log n), список → множествобольшой
реализациязапись выбранного алгоритма на языкепроценты и единицы раз
компиляцияJIT и AOT: Numba, Cythonдесятки и сотни раз
исполнениекеши, ленивые вычислениязависит от данных

Важно

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

Пояснение

Наибольший выигрыш дают архитектура и алгоритмы; ошибка на этих уровнях, найденная поздно, обходится дороже всего.

Встроенные

функции

def count(elements):
    total = 0
    for item in elements:
        total += 1
    return total
 
count(xs)              # 9,6 мс
len(xs)                # 12 нс

[x for x in xs if x % 2]           # 10,4 мс
list(filter(lambda x: x % 2, xs))  # 22,7 мс
 
[s for s in strs if s.isdigit()]   # 13,3 мс
list(filter(str.isdigit, strs))    # 12,4 мс

Пояснение

filter с lambda вызывает функцию Python на каждом элементе и проигрывает включению вдвое; с методом на C filter выигрывает.

Структуры

данных

Операция, миллион элементовСложностьВремя на стенде
x in списокO(n)2,0 мс
x in множествоO(1)11 нс
set(список)O(n)10,9 мс
sorted(data) и data.sort()O(n log n)126 мс

Для физика

Множество выигрывает после пяти-шести поисков: построение длится как пять проверок по списку.

Пояснение

На Python 3.12 сортировка на месте и sorted со своей копией не различаются по времени: копия занимает 1,9 мс из 126.

Важно

Множество занимает больше памяти, чем список: хеш-таблица резервирует запас.

Идиомы

и их цена

БыстрееМедленнее100 000 повторений
if not xif x == False0,77 и 1,12 мс
if not xsif len(xs) == 01,02 и 1,77 мс
[]list()1,44 и 2,20 мс
try при редком исключенииhasattr1,47 и 2,55 мс
hasattr при частом отсутствииtry1,84 и 15,9 мс
локальная переменнаяглобальная1,46 и 2,08 мс

>>> dis.dis('[]')
  1     2 BUILD_LIST     0
>>> dis.dis('list()')
  1     2 PUSH_NULL
        4 LOAD_NAME      0 (list)
        6 CALL           0

Пояснение

Литерал — одна инструкция, а list() требует поиска имени и вызова.

Проверка

замером

def plain(xs):
    result = []
    for x in xs:
        result.append(x)     # 6,1 мс
 
def cached(xs):
    result = []
    append = result.append
    for x in xs:
        append(x)            # 7,7 мс

Совет сохранять метод в локальной переменной годами переходит из подборки в подборку, но с Python 3.11 он проигрывает: интерпретатор специализирует вызов метода в байт-коде.

Важно

Множественное присваивание и подобные приёмы дают проценты ценой читаемости; их применяют, только если профилировщик показал необходимость.

Демонстрация:

приёмы и замер

01

Проверка вхождения: список и множество

02

Стоимость построения множества

03

Глобальная и локальная переменная

04

result.append и сохранённый метод

Что наблюдается

Множество в 180 000 раз быстрее списка, но строится 11 мс; локальная переменная быстрее на 30 %, а сохранённый метод append замедляет заполнение на 26 %.

root@lab — запись со стенда

Память

Измерение памяти, компактные структуры, __slots__, утечки

04

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Измерение

памяти

import sys, tracemalloc
 
xs = [float(i) for i in range(10**6)]
sys.getsizeof(xs)          # 8 МиБ: ссылки
 
tracemalloc.start()
xs = [float(i) for i in range(10**6)]
tracemalloc.get_traced_memory()
# (текущая, пиковая) — 31 МиБ
tracemalloc.stop()

СредствоЧто измеряет
sys.getsizeof(x)размер самого объекта без вложенных
tracemallocвыделения памяти интерпретатором, по строкам кода
массив NumPy: nbytesточный размер данных массива
RSS процесса: ps, topвся память процесса вместе с запасом аллокатора

Важно

getsizeof не учитывает объекты, на которые ссылается контейнер: по его оценке список частиц равен по размеру списку чисел.

Компактные

структуры

СтруктураНа элементПочему
list из float32 байтассылка 8 байт и объект 24 байта
array('d')8 байтчисла подряд, без объектов
np.ndarray float648 байтчисла подряд и операции на C
tuple вместо listбез запасане растёт, не резервирует место под append
__slots__−32 байта на объектнет словаря атрибутов
range, генератор0 байтзначение вычисляется по запросу

class Particle:
    __slots__ = ('x', 'y', 'z')
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z

Пояснение

__slots__ указывают в каждом классе иерархии: иначе словарь атрибутов возвращается.

Демонстрация:

память под данные

01

tracemalloc: память под структуру целиком

02

Список из миллиона float — 30,9 МиБ

03

array и NumPy — 7,8 и 7,6 МиБ

04

Сто тысяч частиц: со словарём и со __slots__

Что наблюдается

Список чисел вчетверо объёмнее массива, а getsizeof учитывает лишь 8 МиБ ссылок; __slots__ экономит четверть памяти.

root@lab — запись со стенда

Утечки

памяти

def append_to(x, acc=[]):     # список один
    acc.append(x)            # на все вызовы
    return acc
 
class Model:
    cache = {}               # не очищается
    def calc(self, arg):
        if arg not in self.cache:
            self.cache[arg] = solve(arg)
        return self.cache[arg]

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

Пояснение

Кеш ограничивают: functools.lru_cache(maxsize=…). Рост памяти показывает сравнение двух снимков tracemalloc.

Компиляция

Numba и Cython на примерах умножения матриц и пространственного заряда

05

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Пример:

умножение матриц

def matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = Matrix.zeros((n_xrows, n_ycols))
    for i in range(n_xrows):
        for j in range(n_xcols):
            for k in range(n_ycols):
                Z[i][k] += X[i][j] * Y[j][k]
    return Z

Чистый Python, 64×64Время
тройной цикл по индексам17,7 мс
перестановка циклов, накопитель11,3 мс
транспонирование и zip8,9 мс
sum(map(operator.mul, row, col))6,3 мс

Пояснение

Приёмы языка ускоряют в 2,8 раза; дальше каждое умножение по-прежнему проходит через интерпретатор.

Numba:

JIT-компиляция

import numba
import numpy as np
 
@numba.njit
def jit_product(X, Y):
    n, m = X.shape
    p = Y.shape[1]
    Z = np.zeros((n, p))
    for i in range(n):
        for k in range(p):
            acc = 0.0
            for j in range(m):
                acc += X[i, j] * Y[j, k]
            Z[i, k] = acc
    return Z

01

При первом вызове

LLVM компилирует функцию в машинный код под типы аргументов

02

Числа и массивы

работает с NumPy, а не со списками объектов

03

Прогрев

замер — после первого вызова: он включает компиляцию

04

Параметры

cache=True — код на диск, parallel=True и prange — по ядрам

Важно

Компиляция не меняет асимптотику: тройной цикл остаётся O(n³).

Демонстрация:

Numba

01

nb.py: умножение 64×64 на списках и массивах

02

Чистый Python — 17,8 мс

03

Первый вызов @njit — 156 мс: компиляция

04

Повторный вызов — 0,08 мс

05

X @ Y в NumPy — 0,06 мс

Что наблюдается

После прогрева Numba быстрее чистого Python в 217 раз; первый вызов тратит 156 мс на компиляцию.

root@lab — запись со стенда

Пространственный

заряд

F[i] = Σ (r[i] − r[j]) / |r[i] − r[j]|³,  j ≠ i

from numba import njit, prange
 
def forces_par(x, y, z, fx):
    for i in prange(len(x)):       # по ядрам
        for j in range(len(x)):
            if i != j:
                r3 = ((x[j]-x[i])**2 + (y[j]-y[i])**2
                      + (z[j]-z[i])**2)**1.5
                fx[i] += (x[i]-x[j]) / r3
 
par = njit(parallel=True)(forces_par)

Для физика

Сильноточный пучок расталкивает сам себя; в коде REDPIC суммирование кулоновских сил между макрочастицами — самая затратная функция расчёта.

Пояснение

Каждая частица взаимодействует с каждой: сложность O(N²), и слагаемые для разных i независимы — внешний цикл распараллеливается.

Демонстрация:

пространственный заряд

01

nproc: два ядра виртуальной машины

02

Цикл Python над списками и над массивами

03

@njit: в 26 раз быстрее цикла над списками

04

parallel=True и prange: ещё вдвое

05

NumPy с массивами N×N

Что наблюдается

При удвоении N время растёт вчетверо — O(N²); @njit быстрее цикла над списками в 26 раз, над массивами — в 120; prange — вдвое на двух ядрах.

root@lab — запись со стенда

Cython:

Python с типами C

@cython.boundscheck(False)
@cython.wraparound(False)
def unchecked(double[:, :] X, double[:, :] Y):
    cdef Py_ssize_t n = X.shape[0], m = X.shape[1]
    cdef Py_ssize_t p = Y.shape[1]
    cdef Py_ssize_t i, j, k
    cdef double acc
    Z = np.zeros((n, p))
    cdef double[:, :] Zv = Z
    for i in range(n):
        for k in range(p):
            acc = 0.0
            for j in range(m):
                acc += X[i, j] * Y[j, k]
            Zv[i, k] = acc
    return Z

01

Сборка

.pyx → cythonize -i → код на C → компилятор → модуль .so

02

Типы

cdef и представления памяти double[:, :] убирают вызовы Python

03

Без проверок

boundscheck и wraparound отключают проверку индексов

04

cython -a

HTML-отчёт: жёлтые строки обращаются к C API Python

Важно

Без проверки границ ошибка индекса не вызывает IndexError, а портит чужую память.

Демонстрация:

Cython

01

cythonize -i: mm.pyx → mm.c → модуль .so

02

untyped: тот же код без типов — 67 мс

03

typed: типы C и представления памяти — 0,11 мс

04

unchecked: без проверок границ — 0,095 мс

05

X @ Y — 0,021 мс

Что наблюдается

Без типов Cython медленнее чистого Python; объявленные типы ускоряют его в 620 раз; NumPy остаётся быстрее.

root@lab — запись со стенда

Numba

или Cython

NumbaCython
компиляцияпри первом вызовепри сборке
что требуетсядекоратор.pyx, компилятор C
типывыводятся по аргументамобъявляются вручную
данныечисла и массивы NumPyлюбые, включая структуры C
сильная стороначисленные циклы без правки кодаобёртки библиотек C и C++

Для физика

Для числового цикла над массивами в научном коде первый выбор — Numba: один декоратор, без сборки.

Пояснение

Cython выбирают, когда нужен готовый модуль без компилятора на стороне пользователя или обёртка библиотеки на C.

NumPy и BLAS

Векторизация: цикл в коде на C, библиотека BLAS, тип данных и плата памятью

06

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Векторизация

NumPy

X @ Y                 # BLAS, float64
X.dot(Y)              # то же самое
 
dx = x[:, None] - x[None, :]    # N×N
r3 = (dx**2 + dy**2 + dz**2)**1.5
np.fill_diagonal(r3, np.inf)
fx = (dx / r3).sum(axis=1)

01

Цикл уходит в C

операция выполняется над массивом целиком

02

BLAS

линейная алгебра на векторных инструкциях и с учётом кеша

03

Тип данных

int64 перемножается без BLAS: на 64×64 вчетверо медленнее, дальше разрыв растёт

04

Плата — память

массивы N×N для пространственного заряда

Демонстрация:

BLAS и тип данных

01

X @ Y для float64 — 19 мкс

02

Те же размеры, int64 — 79 мкс

03

Версия NumPy и библиотека BLAS

Что наблюдается

Целочисленные матрицы NumPy перемножает без BLAS — вчетверо медленнее; чистый Python — в 900 раз дольше float64.

root@lab — запись со стенда

Потоки, процессы и GPU

GIL, пул процессов, Python без GIL, модель CUDA

07

Причины медленности

Измерение

Приёмы языка

Память

Компиляция

NumPy и BLAS

Потоки, процессы и GPU

Процессы

и потоки

import math
from concurrent.futures import ThreadPoolExecutor
 
def integrate(f, a, b, *, n_iter=1000):
    acc, step = 0, (b - a) / n_iter
    for i in range(n_iter):
        acc += f(a + i * step) * step
    return acc
 
def integrate_async(ex, f, a, b, *, n_jobs, n_iter):
    step = (b - a) / n_jobs
    fs = [ex.submit(integrate, f, a + i * step,
                    a + (i + 1) * step,
                    n_iter=n_iter // n_jobs)
          for i in range(n_jobs)]
    return sum(f.result() for f in fs)
 
with ThreadPoolExecutor(max_workers=2) as ex:
    integrate_async(ex, math.cos, 0.0, math.pi / 2,
                    n_jobs=2, n_iter=10**7)

Процесс 1

общая память

поток 1

поток 2

Процесс 2

своя память

поток 1

01

Поток

threading.Thread: start() и join(); общие данные защищают Lock и очередь queue.Queue

02

Исполнитель

ThreadPoolExecutor и ProcessPoolExecutor: submit() возвращает Future, блок with закрывает пул

03

Future

result() ожидает результат, done() проверяет готовность, add_done_callback() — обработчик

GIL:

глобальная блокировка

Вычисления: потоки исполняются по очереди

поток 1

GIL

GIL

GIL

поток 2

GIL

GIL

GIL

Ожидание: на время ввода-вывода GIL свободен

поток 1

GIL

ожидание

GIL

поток 2

вычисления, GIL

@njit(nogil=True)       # Numba: без GIL
def cos_sum(a, b, n): ...
 
with nogil:             # Cython: только типы C
    for i in range(n_iter):
        acc += cos(a + i * step) * step

01

Мьютекс интерпретатора

байт-код исполняет один поток; GIL защищает счётчики ссылок

02

Переключение

ожидающий поток запрашивает GIL через 5 мс: sys.getswitchinterval()

03

Освобождение

блокирующий ввод-вывод, time.sleep, NumPy, Cython с nogil, Numba с nogil=True

Пояснение

Потоки обычной сборки CPython обеспечивают конкурентность — чередование задач, но не параллельное исполнение кода на Python.

Демонстрация:

потоки и GIL

01

nproc: два ядра виртуальной машины

02

Чистый Python: 0,31 с в одном потоке, 0,32 в двух

03

NumPy и Numba с nogil=True: почти вдвое быстрее

04

@njit без nogil: 0,30 с в обоих случаях

05

Восемь ожиданий по 0,25 с: 2,02 → 0,25 с

Что наблюдается

Потоки ускоряют только код, освобождающий GIL: np.sin — в 1,97 раза, Numba с nogil=True — в 1,77, ожидание — в 8 раз; чистый Python — 0,99.

root@lab — запись со стенда

Пул

процессов

import math
from concurrent.futures import ProcessPoolExecutor
 
from integ import integrate_async  # из модуля
 
if __name__ == "__main__":         # модуль импортируется
    a, b = 0.0, math.pi / 2
    with ProcessPoolExecutor(max_workers=2) as ex:
        r = integrate_async(ex, math.cos, a, b,
                            n_jobs=2, n_iter=10**7)
        f = ex.submit(lambda x: x * x, 3)
        f.result()                 # PicklingError

Важно

Функция и аргументы передаются через pickle, функция — по имени: lambda не передаётся, а функцию из ячейки блокнота при spawn и forkserver рабочий процесс не находит.

МетодНовый процессПо умолчанию
forkкопия родительского процессаLinux по 3.13
spawnновый интерпретатор, импорт главного модуляWindows, macOS с 3.8
forkserverкопия заранее запущенного сервераLinux с 3.14

Пояснение

Запуск процессов и пересылка данных требуют времени: выигрыш дают крупные независимые задачи, а пул создаётся один раз.

Демонстрация:

пул процессов

01

Python 3.12, метод fork: 0,31 → 0,18 с

02

Python 3.14, метод forkserver: 0,39 → 0,21 с

03

Запуск пула: 2 мс при fork, 30 мс при forkserver

04

lambda в пуле процессов: PicklingError

Что наблюдается

Два процесса на двух ядрах ускоряют чистый Python в 1,7–1,9 раза; forkserver запускает пул на порядок дольше fork; lambda не сериализуется.

root@lab — запись со стенда

Python

без GIL

ВерсияСборка без GIL
3.13экспериментальная, PEP 703: python3.13t
3.14поддерживаемая, PEP 779: python3.14t

$ python3.14t -VV
Python 3.14.8 free-threading build ...
>>> import sys; sys._is_gil_enabled()
False
$ python3.14t -X gil=1 app.py      # с GIL
$ PYTHON_GIL=1 python3.14t app.py  # то же

Пояснение

Обычная сборка с GIL остаётся сборкой по умолчанию; свободнопоточная устанавливается рядом.

01

Потоки на всех ядрах

байт-код исполняется параллельно; list, dict и set защищены своими блокировками

02

Гонки остаются

counter += 1 не атомарна: общие данные по-прежнему защищает Lock

03

Расширения на C

объявляют поддержку; модуль без объявления снова включает GIL

04

Цена

однопоточный код медленнее: в 3.13 — на 40 %, в 3.14 — на 1–8 %

Демонстрация:

Python без GIL

01

python3.14t -VV: free-threading build

02

-X gil=1: два потока — 0,39 с, как один

03

-X gil=0: два потока — 0,20 с вместо 0,38

04

Счётчик без Lock: 1 118 818 из 2 000 000

Что наблюдается

Без GIL чистый Python в двух потоках на двух ядрах ускоряется в 1,9 раза; счётчик без Lock теряет 44 % увеличений, с Lock результат точный.

root@lab — запись со стенда

Видеокарта:

SIMT и warp

CPUGPU
ядра4–64 сложныхтысячи простых
оптимизирован подзадержку одной задачипропускную способность
задержку памяти скрываютбольшие кешитысячи нитей
полоса памятидесятки ГБ/ссотни ГБ/с — единицы ТБ/с
нагрузкаветвистая, последовательнаяоднотипная над массивами

Для физика

Частицы, узлы сетки, события детектора — одна формула над миллионами независимых элементов.

01

Warp и SIMT

32 нити выполняют одну инструкцию над разными данными

02

Дивергенция

ветви if внутри warp выполняются по очереди

03

Иерархия памяти

регистры нити, разделяемая память блока, глобальная память карты

04

Объединение обращений

запросы warp сливаются в 32-байтные транзакции: соседним нитям — соседние адреса

Модель

CUDA

Сетка (grid): 3 блока по 4 нити, n = 10

блок 0: i = 0…3

0

1

2

3

блок 1: i = 4…7

0

1

2

3

блок 2: i = 8…11

0

1

2

3

i = blockIdx.x · blockDim.x + threadIdx.x

Нить 1 блока 2: i = 2 · 4 + 1 = 9; серые нити 2 и 3 блока 2 простаивают: i ≥ n

Host

CPU и оперативная
память

PCIe

Device

GPU и память
карты

from numba import cuda
@cuda.jit
def add_kernel(a, b, out):
    i = cuda.grid(1)        # глобальный индекс
    if i < out.size:        # лишние нити не работают
        out[i] = a[i] + b[i]
 
d_a, d_b = cuda.to_device(a), cuda.to_device(b)
d_out = cuda.device_array_like(d_a)
tpb = 256                   # нитей в блоке
bpg = (n + tpb - 1) // tpb  # блоков
add_kernel[bpg, tpb](d_a, d_b, d_out)
out = d_out.copy_to_host()  # device → host

Пояснение

Встроенный numba.cuda устарел с Numba 0.61; NVIDIA развивает numba-cuda-mlir, numba-cuda только сопровождается.

Данные

на видеокарте

import cupy as cp       # numpy — тот же код на CPU
 
h = q * dt / (2 * m)              # схема Бориса
t = h * B0
s = 2 * t / (1 + t * t)
rng = cp.random.default_rng(42)   # сразу на GPU
x = rng.standard_normal(N, dtype=cp.float32) * 1e-3
y = rng.standard_normal(N, dtype=cp.float32) * 1e-3
vx = rng.standard_normal(N, dtype=cp.float32) * 1e5
vy = rng.standard_normal(N, dtype=cp.float32) * 1e5
y0 = float(y.mean())              # центр облака
for step in range(n_steps):
    vx += h * E0                  # полшага поля E
    px, py = vx + vy * t, vy - vx * t
    vx, vy = vx + py * s, vy - px * s  # поворот
    vx += h * E0
    x += vx * dt
    y += vy * dt
v = (float(y.mean()) - y0) / (n_steps * dt)  # на host

01

Шина и память карты

PCIe x16 — 16–64 ГБ/с; память GPU — до нескольких ТБ/с

02

Оценка для 1 ГБ

пересылка при 16 ГБ/с — около 60 мс, y = 2x на карте — около 2 мс

03

Асинхронный запуск

вызов ставит ядро в очередь; замер — после прогрева и synchronize()

04

CuPy

интерфейс NumPy на GPU; cp.asnumpy и cp.asarray копируют данные

Важно

Данные создаются на карте и остаются там до конца расчёта; на host возвращается результат.

Демонстрация:

ядро CUDA на CPU

01

cuda.is_available(): видеокарты на стенде нет

02

Симулятор CUDA: ядро на CPU, 3 блока по 4 нити

03

Индекс i = блок · 4 + нить; нити с i = 10 и 11 простаивают

04

Дрейф 10⁵ электронов: NumPy вместо CuPy

Что наблюдается

Симулятор проверяет индексы, а не скорость; скорость дрейфа −9,998·10⁴ м/с совпадает с −E0/B0 с точностью 0,02 %.

root@lab — запись со стенда

Итог

замеров

ЗадачаСредствоВыигрыш на стенде
цикл Python над спискамиприёмы языкав 2,8 раза
числовой циклNumba и Cythonв 217 и 165 раз
линейная алгебраNumPy и BLASв 900 раз
цикл Numba, операции NumPyprange, nogil=True, потокив 1,8–2 раза
чистый Python на всех ядрахпул процессов, python3.14tв 1,7–1,9 раза
ожидание ввода-выводапотоки, asyncioв 8 раз при восьми ожиданиях
массовый параллелизм данныхGPU: CuPy, numba-cuda-mlirвидеокарты на стенде нет

Важно

На двух ядрах стенда параллелизм дал не больше двух раз, компиляция и векторизация — сотни.

Для физика

Перебор параметров установки — естественная задача для пула процессов: расчёты независимы.

Итоги

лекции

01

Медленность Python обусловлена динамической типизацией, изменяемостью и циклом интерпретатора над объектами.

02

Ускоряют только измеренное: timeit для фрагмента, cProfile и line_profiler для программы, py-spy для работающего процесса.

03

Структура данных и алгоритм важнее микрооптимизаций; любой совет проверяется замером на своей версии Python.

04

Память измеряют tracemalloc; числа хранят в массивах, объекты — со __slots__.

05

NumPy, Numba и Cython ускоряют горячий цикл в десятки и сотни раз.

06

Потоки ускоряют ожидание и код без GIL; Python на всех ядрах — процессы или сборка без GIL; работа над массивами — видеокарта.

Упражнения

для самопроверки

01

Профиль своего кода

cProfile и line_profiler на программе из проекта: какая функция и строка горячие

02

Пространственный заряд

версия со сглаживанием r² + ε² на Numba; проверка закона N² и выигрыша prange

03

Память частиц

список объектов, __slots__ и массив структур NumPy для миллиона частиц

04

Векторизация блоками

силы без массивов N×N: разбиение на блоки и сравнение с Numba

05

Параллельный перебор

ансамбль расчётов в пуле процессов и в потоках python3.14t: ускорение и число ядер

06

Дрейф на видеокарте

пример с CuPy в Colab: время с пересылкой массивов на каждом шаге и без неё

Пояснение

Каждое ускорение сравнивается с исходной версией на тех же данных и проверяется тестом.

Для физика

Проект курса: горячий цикл расчёта — первый кандидат на профиль и Numba.

Источники

1

Книга курса: раздел «Ускорение расчётов», главы об оптимизации, скорости, GIL и CUDA

phys-dev.github.io/soft-dev-book

2

Лебедев С. Быстрее, Python, ещё быстрее. – Computer Science Center, 2015

youtube.com/watch?v=-lMiAKKyLFI

3

Лебедев С. Многопоточность и GIL. – Computer Science Center, 2015

youtube.com/watch?v=nR8WhdcRJwM

4

timeit, cProfile, tracemalloc, concurrent.futures, multiprocessing, free threading — документация Python

docs.python.org/3/howto/free-threading-python.html

5

py-spy — документация; line_profiler — kernprof.readthedocs.io

github.com/benfred/py-spy

6

Numba — документация; numba-cuda и numba-cuda-mlir — nvidia.github.io; CuPy — docs.cupy.dev

numba.readthedocs.io

7

Cython — документация

cython.readthedocs.io

8

Knuth D. Structured Programming with go to Statements. – ACM Computing Surveys, 1974

dl.acm.org/doi/10.1145/356635.356640

9

Gorelick M., Ozsvald I. High Performance Python. – 3rd ed. – O'Reilly, 2025

Спасибо за внимание

Вопросы

phys-dev.github.io/soft-dev-book

@njit