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

Проект: Мандельброт на Numba и итоги

О чём эта тема
Финал линии P: тот же фрактал, что в C08, — теперь через @cuda.jit и PIL, плюс случайные числа на видеокарте, Монте-Карло и сводный ответ на вопрос «когда C++, а когда Numba».
Аннотация
Проект намеренно повторяет C08: ядро Мандельброта переводится на Python строка в строку — двумерный cuda.grid, та же математика z ← z² + c, — и результат, проверенный в симуляторе, совпадает с эталонной numpy-реализацией до последнего пикселя. Вместо самодельного PPM картинку сохраняет PIL — привилегия Python-мира. Второй сюжет — случайные числа: у тредов не может быть общего генератора, поэтому CUDA раздаёт каждому свой поток xoroshiro128+; на них строится Монте-Карло-оценка π (проверено: 3.1172 на 32 тысячах точек). Замыкают линию сюрприз @vectorize(target='cuda') — формула из P03, переносимая на видеокарту без изменений, — и сводная таблица выбора инструмента. Тренажёр — живой Мандельброт на canvas: та же математика, что в ядре, плюс зум по щелчку и наглядная дивергенция варпов.
Пререквизиты
Вся линия P (P04, P05 — обязательно); родственный C08 — тот же проект на C++, интересно читать параллельно.
Мотивация
Лучший способ понять два инструмента — сделать ими одну и ту же вещь. Фрактал из C08 здесь собирается заново за полчаса вместо вечера: без nvcc, без PPM-заголовков, с картинкой через PIL. А заодно закрывается последняя технически важная тема — параллельные случайные числа, без которых нет ни Монте-Карло, ни стохастических симуляций. На выходе — трезвое резюме: что дала линия C, что дала линия P и как выбирать между ними в собственном проекте.

1. Фрактал: перевод с C++ на Python

Ядро из C08 в Numba-исполнении — сравните с оригиналом, различия только синтаксические (словарь P04 в действии):

import os
# os.environ["NUMBA_ENABLE_CUDASIM"] = "1"   # раскомментируйте на машине без NVIDIA
import numpy as np
from numba import cuda

@cuda.jit
def mandel_kernel(iters, max_iter):
    px, py = cuda.grid(2)                  # двумерный грид — по треду на пиксель
    h, w = iters.shape
    if px >= w or py >= h:
        return                             # защита по обеим осям (C04)

    cr = -2.2 + 3.0 * px / w               # пиксель → точка плоскости
    ci = -1.0 + 2.0 * py / h

    zr = 0.0; zi = 0.0; k = 0
    while k < max_iter and zr*zr + zi*zi <= 4.0:
        zr, zi = zr*zr - zi*zi + cr, 2*zr*zi + ci
        k += 1
    iters[py, px] = k

W, H, MAX_ITER = 900, 600, 200
d_img = cuda.device_array((H, W), dtype=np.int32)       # сразу на устройстве (P05)

block = (16, 16)
grid = ((W + 15) // 16, (H + 15) // 16)
mandel_kernel[grid, block](d_img, MAX_ITER)
img = d_img.copy_to_host()

# сохранение — на этот раз без самодельного PPM: у Python есть PIL
from PIL import Image
t = (img / MAX_ITER) ** 0.45                            # гамма для контраста
rgb = np.dstack([(40 + 200*t), (25 + 170*t), (70 + 120*t)]).astype(np.uint8)
Image.fromarray(rgb).save("mandelbrot.png")

Корректность проверена жёстко: ядро прогнано в симуляторе на уменьшенном поле и сравнено с эталонной последовательной реализацией — совпадение до последнего пикселя (np.array_equal, без допусков: целые числа итераций). Картинка из этого алгоритма — в C08, раздел 3: там она вычислена той же математикой. Замечание о производительности справедливо и здесь, как в C08: у границы множества соседние пиксели требуют разного числа итераций — варпы расходятся, и это видно даже в JS-тренажёре ниже по времени кадра.

Типичная ошибка Запустить пример в симуляторе на полном разрешении и решить, что «Numba жутко медленная»: 900×600 пикселей интерпретатором — это минуты. Симулятор — для корректности на маленьких данных (60×40 проверяется за секунды), скорость — только на настоящем GPU (Colab). Ошибка-сестра из P04, но на проектных размерах она больно кусается.

2. Случайные числа для тысяч тредов

Монте-Карло из P01 на видеокарте упирается в неожиданное препятствие: обычный генератор случайных чисел — это состояние, которое каждое обращение читает и перезаписывает. Дать тысячам тредов один генератор — устроить гонку (P02) и коррелированные «случайности». Решение CUDA: каждому треду — собственный поток генератора xoroshiro128+, инициализированный так, чтобы потоки не пересекались:

from numba.cuda.random import (create_xoroshiro128p_states,
                                xoroshiro128p_uniform_float32)

@cuda.jit
def mc_pi(states, per_thread, hits):
    t = cuda.grid(1)
    inside = 0
    for i in range(per_thread):
        x = xoroshiro128p_uniform_float32(states, t)   # t — номер СВОЕГО потока
        y = xoroshiro128p_uniform_float32(states, t)
        if x*x + y*y <= 1.0:
            inside += 1
    hits[t] = inside                                   # каждый пишет в свою ячейку — map

threads, blocks, per = 32, 2, 500
states = create_xoroshiro128p_states(threads * blocks, seed=42)
hits = np.zeros(threads * blocks, dtype=np.int32)
mc_pi[blocks, threads](states, per, hits)
print(4.0 * hits.sum() / (threads * blocks * per))    # 3.1172 — проверено

Внутри знакомая механика: каждый тред копит попадания в свой счётчик (map по тредам), финальная сумма — редукция на хосте. Обратите внимание на seed: генератор детерминирован — с одним зерном результат воспроизводится точно, что бесценно для отладки стохастических программ. В симуляторе пример выдаёт ворох предупреждений NumbaWarning при инициализации состояний — это издержки эмуляции, на настоящем GPU их нет.

И обещанный сюрприз из P03: третий target декоратора @vectorize. Формула, написанная для одного элемента, переносится на видеокарту буквально одним словом — Numba сама строит ядро, грид и все копирования данных:

@vectorize(['float32(float32)'], target='cuda')
def expit_gpu(x):
    return 1.0 / (1.0 + math.exp(-x))     # та же сигмоида из P03 — теперь на GPU

3. Итоги траектории: когда C++, когда Numba

Обе линии сошлись в одной точке — один фрактал, две реализации. Пора выдать сводную таблицу выбора:

КритерийCUDA C++ (линия C)Numba (линия P)
порог входа Toolkit, nvcc, системный компилятор, ручная память pip install numba; numpy-массивы; симулятор без GPU
скорость ядер эталон; тонкий контроль (потоки CUDA, текстуры, библиотеки cuBLAS/Thrust) сопоставимая для обычных ядер — PTX тот же; экзотика API недоступна
скорость разработки часы: компиляция, сборка, printf-отладка минуты: интерактивный цикл, отладчик в симуляторе
интеграция в C++-проекты, движки, продакшен-сервисы в научный Python-стек: numpy, PIL, matplotlib, Jupyter
чему учит устройству железа — без этого не понять ни один инструмент быстрой проверке идей на том же железе

Практическое правило на выпуск: думайте на языке линии C, прототипируйте на языке линии P. Модель «грид → блоки → варпы», этажи памяти, паттерны map/reduce/scan и критерии рентабельности копирований — общие; какой синтаксис их записывает, вопрос второй. Прототип на Numba за вечер покажет, окупается ли GPU для вашей задачи, — а уже потом решайте, нужна ли пересборка на C++ ради последних процентов и интеграции.

4. Тренажёр: Мандельброт вживую

Тренажёр · та же математика, что в ядре

Canvas считает ровно тот цикл, что mandel_kernel, — по «треду» на пиксель, только последовательно. Щёлкните, чтобы приблизить точку (колесо истории — кнопкой «сброс»); следите за временем кадра: у границы множества оно растёт — то самое «варпы ждут самого медленного», видимое глазами.

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

Источники

  1. Random Number Generation // Numba Documentation : [сайт]. — URL: https://numba.readthedocs.io/en/latest/cuda/random.html (дата обращения: 09.07.2026).
  2. CUDA Ufuncs and Generalized Ufuncs // Numba Documentation : [сайт]. — URL: https://numba.readthedocs.io/en/latest/cuda/ufunc.html (дата обращения: 09.07.2026).
  3. CUDA by Numba Examples // NVIDIA Developer Blog : [сайт]. — URL: https://developer.nvidia.com/blog/cuda-by-numba-examples-1/ (дата обращения: 09.07.2026).
  4. Mandelbrot set // Wikipedia : [сайт]. — URL: https://en.wikipedia.org/wiki/Mandelbrot_set (дата обращения: 09.07.2026).