Траектория «Параллельные вычисления» · линия P · конспект 3 из 6

Ufunc за минуту: @vectorize и @stencil

О чём эта тема
Два декоратора для двух вездесущих паттернов: @vectorize превращает скалярную формулу в полноценную универсальную функцию numpy (map), а @stencil описывает вычисления по окрестности — размытия, фильтры, клеточные автоматы.
Аннотация
После P02 известно, что map параллелится тривиально, — этот конспект убирает из map даже цикл. @vectorize получает функцию одного элемента и строит ufunc: она сама применяется ко всему массиву, сама поддерживает broadcast со скалярами и, с target='parallel', сама раскладывается по ядрам — сигмоида на десяти миллионах чисел ускоряется с 84 до 10 мс против numpy. Родственник @guvectorize обобщает идею на «функцию одной строки». Вторая половина — @stencil для вычислений по окрестности: ядро описывается относительными индексами m[-1, 0], m[0, 1], границы обрабатываются автоматически. Сквозной пример — игра «Жизнь» Конвея одним стенсилом, проверенная на классическом глайдере: шаг поля 2000×2000 занимает 2 мс. Тренажёр — та же «Жизнь» вживую на canvas рядом с кодом стенсила. Все замеры проверены запуском; скрипт — в папке сode.
Пререквизиты
P01 — @njit и прогрев; P02 — паттерн map и target='parallel' под капотом.
Мотивация
Больше половины числового кода — это «примени формулу к каждому элементу» и «посчитай по соседям». Оба паттерна можно писать циклами с prange — а можно объявить одной функцией без единого цикла, коротко и без места для ошибки в индексах. Декораторы этого конспекта — самые «дешёвые» инструменты линии: минимум нового синтаксиса, максимум пользы на строку. А в P04 у @vectorize обнаружится сюрприз: параметр target='cuda' отправляет ту же формулу на видеокарту без единого изменения.

1. @vectorize: своя универсальная функция

Универсальные функции (ufunc) — это np.exp, np.sqrt и прочие: они принимают массив любой формы, применяются поэлементно, дружат с broadcast. @vectorize позволяет добавить в этот клуб свою формулу — писав её для одного элемента:

from numba import vectorize

@vectorize(['float64(float64)'])          # сигнатура: из float64 в float64
def expit(x):                              # логистическая сигмоида
    return 1.0 / (1.0 + np.exp(-x))       # написана для ОДНОГО числа

expit(a)          # ...а работает с массивом любой формы
expit(0.0)        # и со скаляром

Чем это лучше numpy-выражения 1 / (1 + np.exp(-a))? Numpy выполняет его в три прохода: посчитать −a во временный массив, exp — во второй, деление — в третий; десять миллионов элементов трижды пройдут через всю память. Ufunc от @vectorize делает один проход без временных массивов, а target='parallel' раздаёт его по ядрам (тот же map из P02):

ВариантВремя (10⁷ элементов)Комментарий
1 / (1 + np.exp(-a))84 мсnumpy: три прохода, два временных массива
@vectorize38 мсодин проход, одно ядро
@vectorize(…, target='parallel')10 мсодин проход по всем ядрам

Бонусы клуба ufunc достаются бесплатно: broadcast (наша функция двух аргументов сама сообразит, что второй — скаляр), методы .reduce и .accumulate, работа с любыми формами массивов. Третий target — 'cuda' — оставим до P04: это та же строка кода, выполняющаяся на видеокарте.

Типичная ошибка Использовать внутри @vectorize операции над массивами: формула должна быть написана для одного элемента — со скалярами на входе и одним скаляром на выходе. Если внутри появились срезы, суммы по массиву или соседние элементы — это уже не поэлементная операция: для «функции от строки» есть @guvectorize, для окрестностей — @stencil.

@guvectorize — обобщение на случай «вход — строка, выход — строка»: сигнатура формы записывается строкой вида '(n),()->(n)' («из массива длины n и скаляра — массив длины n»), и функция автоматически применяется к каждой строке многомерного входа. Пример — скользящее среднее (проверено: от [0,1,2,3,4,5] с окном 3 получается [0, 0.5, 1, 2, 3, 4]):

from numba import guvectorize

@guvectorize(['(float64[:], int64, float64[:])'], '(n),()->(n)')
def moving_avg(row, w, out):          # out приходит готовым — заполняем его
    acc = 0.0
    for i in range(row.size):
        acc += row[i]
        if i >= w:
            acc -= row[i - w]
        out[i] = acc / min(i + 1, w)

2. @stencil: вычисления по окрестности

Второй вездесущий паттерн — «новое значение точки считается из её соседей»: размытие картинок, разностные схемы для уравнений физики, клеточные автоматы. Писать его циклами муторно из-за границ (у крайних точек нет соседей слева) и легко ошибиться в индексах. @stencil описывает только суть — ядро в относительных координатах:

from numba import stencil, njit

@stencil
def blur_kernel(m):
    # m[0, 0] — текущая точка; m[-1, 0] — сосед сверху, m[0, 1] — справа…
    return (m[-1, 0] + m[1, 0] + m[0, -1] + m[0, 1] + m[0, 0]) / 5.0

@njit                                     # стенсил вызывается из njit-функции
def blur(m):
    return blur_kernel(m)

Numba сама генерирует двойной цикл по всем точкам, а граничные точки, которым не хватает соседей, по умолчанию получают ноль (границу можно настроить). Стенсил внутри @njit(parallel=True) автоматически параллелится — это map по точкам.

Сквозной пример — игра «Жизнь» Конвея: клетка living=1/dead=0, у каждой 8 соседей; живая выживает при 2–3 соседях, мёртвая оживает ровно при 3. Весь автомат — один стенсил:

@stencil
def life_kernel(m):
    neigh = (m[-1,-1] + m[-1,0] + m[-1,1] +
             m[0,-1]            + m[0,1] +
             m[1,-1]  + m[1,0] + m[1,1])
    return np.uint8((neigh == 3) or (m[0,0] and neigh == 2))

@njit
def life_step(m):
    return life_kernel(m)

Проверка корректности — классическая фигура глайдер: пять клеток, которые за четыре поколения воспроизводят себя со сдвигом на одну клетку по диагонали. Тест запущен: через 4 шага поле в точности равно исходному, сдвинутому np.roll на (1, 1). Скорость — шаг поля 2000×2000 за 2 мс. Заметьте важное свойство: «Жизнь» — это scan по времени (поколение зависит от предыдущего — не распараллелишь), но map по пространству (все клетки поколения считаются одновременно). Разложение задачи на «последовательное время × параллельное пространство» — стандартная судьба физических симуляций.

Типичная ошибка Обновлять поле «на месте», в том же массиве: тогда сосед слева уже из нового поколения, а сосед справа ещё из старого, и автомат вычисляет нечто третье. Стенсил от этого защищает сам — он всегда пишет в новый массив; но при ручной реализации циклом двойная буферизация (читаем из old, пишем в new, меняем местами) — на вашей совести. Та же ошибка в CUDA-варианте всплывёт в P05 — там это называется «гонка чтения-записи».

3. Тренажёр: «Жизнь» вживую

Тренажёр · клеточный автомат стенсила

То самое правило из life_kernel, шаг за шагом. Щёлкайте по полю, чтобы рисовать клетки; «глайдер» подсаживает классическую фигуру — проследите, как она за 4 поколения сдвигается по диагонали (наш тест корректности).

Поколение 0.

Контрольные вопросы

Источники

  1. Creating NumPy universal functions (@vectorize, @guvectorize) // Numba Documentation : [сайт]. — URL: https://numba.readthedocs.io/en/latest/user/vectorize.html (дата обращения: 09.07.2026).
  2. Using @stencil // Numba Documentation : [сайт]. — URL: https://numba.readthedocs.io/en/latest/user/stencil.html (дата обращения: 09.07.2026).
  3. Conway's Game of Life // Wikipedia : [сайт]. — URL: https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life (дата обращения: 09.07.2026).