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

Финальные проекты: битонная сортировка и Мандельброт

О чём эта тема
Два законченных проекта, где встречается всё выученное: сортировка шестнадцати миллионов чисел битонной сетью и фрактал Мандельброта, нарисованный по треду на пиксель, — плюс обзор задач, которые студенты решают на CUDA дальше.
Аннотация
Сортировка кажется врагом параллелизма: сравнения зависят друг от друга. Битонная сортировка обходит это, заменяя «умные» зависимые сравнения фиксированной сетью сравнений, известной до запуска, — идеальная пища для GPU. Конспект разбирает битонные последовательности, устройство сети, магию строчки ixj = i ^ j и полное ядро с двумя вложенными циклами на хосте. Второй проект — множество Мандельброта: двумерный грид из C07 красит по пикселю на тред, результат пишется в PNG-совместимый формат PPM голыми руками, без единой сторонней библиотеки. В финале — короткая экскурсия по настоящим студенческим проектам: трассировка лучей, сглаживание SSAA, классификация по Махаланобису. Тренажёр прокручивает битонную сеть на восьми живых числах.
Пререквизиты
Вся линия: C04 — индексация и границы, C05 — синхронизация, C06 — замеры, C07 — двумерный грид.
Мотивация
Учебные ядра позади; пора убедиться, что из них собираются настоящие программы. Сортировка и фрактал выбраны не случайно: первая показывает, как перепридумывают алгоритм целиком, когда классика (quicksort) параллелится плохо; второй — как GPU рисует картинку из чистой математики и почему это происходит в тысячи раз быстрее наивного цикла. После них вы готовы к собственному проекту — примеры тем в конце.

1. Сортировка без ветвлений: битонная сеть

Быстрая сортировка на GPU буксует: её сравнения зависят от данных — куда пойдёт элемент, известно только в момент сравнения, а ветвящиеся треды внутри варпа выполняются по очереди (SIMT, C02). Хочется алгоритм, где расписание сравнений фиксировано заранее и не зависит от данных вовсе. Такие алгоритмы называются сортирующими сетями, и самая знаменитая из них — битонная (Кен Бэтчер, 1968).

Битонной называется последовательность, которая сначала не убывает, а затем не возрастает (или наоборот): например, {3, 5, 8, 9, 7, 4, 2, 1}. Ключевое свойство: если к битонной последовательности длины n применить «полуочистку» — сравнить элемент i с элементом i + n/2 и поставить меньший влево, — обе половины станут битонными, причём все элементы левой не превосходят всех элементов правой. Рекурсивно повторяя, за log₂ n раундов получаем отсортированную последовательность. А битонную последовательность из произвольной строят так же: сортируют половины во встречных направлениях. Итого сеть состоит из log₂ n «этапов», этап k — из log₂ k проходов, и на каждом проходе все n/2 сравнений независимы — по сравнению на тред:

a[0]a[1] a[2]a[3] a[4]a[5] a[6]a[7] k=2, j=1 k=4: j=2, j=1 k=8: j=4, j=2, j=1

Фиолетовые сравнения ставят меньший элемент вверх, оранжевые — вниз: встречные направления первых этапов и создают битонность. Для n = 8 сеть насчитывает 6 проходов и 24 сравнения; для миллиона элементов — 210 проходов по полмиллиона одновременных сравнений каждый. Больше сравнений, чем у quicksort (O(n log² n) против O(n log n)), — но все они ложатся на треды без единого зависимого ветвления. Классический размен этой траектории: больше работы, зато вся параллельная.

2. Ядро: вся магия в i ^ j

Каждый проход сети — отдельный запуск ядра: параметры k (размер сливаемых кусков) и j (текущий шаг сравнения) приходят с хоста, тред i решает, с кем сравниваться, одной битовой операцией:

__global__ void bitonic_step(float* a, int j, int k)
{
    unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
    unsigned int ixj = i ^ j;          // партнёр: тот же индекс с перевёрнутым битом j

    if (ixj > i) {                     // пару обрабатывает только «левый» тред
        bool up = ((i & k) == 0);      // бит k задаёт направление куска
        if (up ? (a[i] > a[ixj]) : (a[i] < a[ixj])) {
            float t = a[i]; a[i] = a[ixj]; a[ixj] = t;   // обмен
        }
    }
}

// хост: расписание сети — два вложенных цикла
for (int k = 2; k <= n; k <<= 1)          // этапы: 2, 4, 8, …, n
    for (int j = k >> 1; j > 0; j >>= 1) // проходы этапа: k/2, k/4, …, 1
        bitonic_step<<<n / 1024, 1024>>>(dev_a, j, k);

Разберём три строки, в которых всё дело. i ^ j — исключающее ИЛИ: у числа i переворачивается ровно тот бит, который установлен в j (j — всегда степень двойки). Пары «i ↔ i с перевёрнутым битом» — это и есть сравнения прохода: при j = 1 соседи, при j = 4 — элементы на расстоянии 4. Условие ixj > i оставляет из каждой пары одного работника — иначе оба треда попытались бы обменять одну пару дважды. Наконец, i & k проверяет бит k: у первой половины каждого куска длины k он нулевой (сортируем вверх), у второй — единичный (вниз) — так чередуются направления, создающие битонность.

Почему между проходами не нужен __syncthreads? Потому что синхронизация здесь — сам запуск ядра: следующий bitonic_step не начнётся, пока не завершился предыдущий (очередь устройства последовательна). Это стандартный способ глобальной синхронизации «всех со всеми», которой внутри одного запуска не существует (C05): барьер между блоками — это граница между ядрами.

Типичная ошибка Запустить сеть на n, не являющемся степенью двойки. Сеть Бэтчера определена только для 2^m элементов: на «рваной» длине пары i ^ j выйдут за границы массива. Стандартное решение — дополнить массив до ближайшей степени двойки значениями +∞ (для float — FLT_MAX): паддинг всплывает в конец и отрезается при копировании результата.

Для сравнения масштабов: соседний проект из студенческого репозитория — чёт-нечётная сортировка (odd-even transposition) — устроена ещё проще: n проходов «сравни соседей через одного», тоже без зависимых ветвлений. Но проходов у неё n, а не log² n: на миллионе элементов это миллион запусков ядра против двухсот десяти. Битонная сеть — редкий случай, когда более хитрая математика окупается тысячекратно.

3. Мандельброт: по треду на пиксель

Второй проект — из мира красоты. Множество Мандельброта — точки c комплексной плоскости, для которых итерация z ← z² + c, начатая с нуля, не убегает в бесконечность. Рисуют его так: каждому пикселю — своя точка c; крутим итерацию, пока |z| ≤ 2, но не больше MAX_ITER раз; число сделанных итераций превращается в цвет. Пиксели полностью независимы — чистейший map на двумерном гриде из C07:

#define W 900
#define H 600
#define MAX_ITER 200

__global__ void mandelbrot(unsigned char* iters)
{
    int px = blockIdx.x * blockDim.x + threadIdx.x;
    int py = blockIdx.y * blockDim.y + threadIdx.y;
    if (px >= W || py >= H) return;               // защита из C04, обе оси

    // пиксель → точка комплексной плоскости [-2.2, 0.8] × [-1, 1]
    float cr = -2.2f + 3.0f * px / W;
    float ci = -1.0f + 2.0f * py / H;

    float zr = 0, zi = 0;
    int k = 0;
    while (k < MAX_ITER && zr * zr + zi * zi <= 4.0f) {
        float t = zr * zr - zi * zi + cr;         // z = z² + c, вручную
        zi = 2 * zr * zi + ci;                    // по действительной и мнимой части
        zr = t;
        k++;
    }
    iters[py * W + px] = (unsigned char)(k * 255 / MAX_ITER);
}

Хост запускает ядро сеткой dim3 grid((W+15)/16, (H+15)/16), block(16,16), забирает массив и сохраняет картинку. Оригинальный студенческий проект показывал её через OpenCV — тяжёлую библиотеку компьютерного зрения, которую ради одной картинки ставить жалко. Обойдёмся без неё: формат PPM — это картинка в виде «заголовок + голые байты», её пишет любой C++ без зависимостей, а открывает любой просмотрщик (и Paint, и GIMP; конвертация в PNG — один клик):

// хост, после cudaMemcpy результата в unsigned char img[W*H]:
FILE* f = fopen("mandelbrot.ppm", "wb");
fprintf(f, "P6\n%d %d\n255\n", W, H);        // заголовок: формат, размер, максимум
for (int i = 0; i < W * H; i++) {
    unsigned char v = img[i];
    unsigned char rgb[3] = { v, (unsigned char)(v / 2), (unsigned char)(128 - v / 2) };
    fwrite(rgb, 1, 3, f);                   // нехитрая палитра из яркости
}
fclose(f);
Множество Мандельброта, вычисленное описанным алгоритмом: чёрное кардиоидное тело фрактала на фиолетовом градиенте убегающих точек
Результат алгоритма из этого раздела (900×600, MAX_ITER = 200) — та же математика, окно [−2.2, 0.8] × [−1, 1]

Пуант проекта — в цифрах. Пикселей 540 тысяч, у каждого до 200 итераций: на CPU однопоточный цикл делает это за секунды, GPU — за единицы миллисекунд, потому что пиксели считаются не по очереди, а по 540 тысяч сразу (насколько позволяет карта). Причём здесь вычислений на каждый байт результата приходится очень много — сотни операций на один записанный байт: по критериям рентабельности из C06 это идеальная задача для видеокарты. Любопытная деталь: соседние пиксели у границы множества делают сильно разное число итераций — треды варпа расходятся по while, и варп работает со скоростью самого медленного (SIMT, C02). Фрактал — живая иллюстрация дивергенции варпа; сгладить её — первое упражнение для любопытных.

5. Тренажёр: сеть на живых числах

Тренажёр · битонная сортировка по проходам

Восемь чисел, шесть проходов сети. Кнопка «проход» выполняет все сравнения текущей пары (k, j) одновременно — как один запуск ядра. Цвет ячейки показывает направление её куска: ■ вверх (i & k = 0) · ■ вниз

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

Благодарность Линия C этой траектории опирается на курс «Программирование CUDA» Лаборатории суперкомпьютерных и квантовых вычислений ДВФУ — https://cc.dvfu.ru/.

Источники

  1. Batcher K. E. Sorting networks and their applications // Proceedings of the AFIPS Spring Joint Computer Conference. — 1968. — P. 307–314.
  2. Bitonic sorter // Wikipedia : [сайт]. — URL: https://en.wikipedia.org/wiki/Bitonic_sorter (дата обращения: 09.07.2026).
  3. CUDA C++ Programming Guide // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-programming-guide/ (дата обращения: 09.07.2026).
  4. Netpbm format (PPM) // Wikipedia : [сайт]. — URL: https://en.wikipedia.org/wiki/Netpbm (дата обращения: 09.07.2026).