Мы можем превзойти производительность функции NumPy на целых 50% с помощью математики
Двумерное преобразование Фурье — один из важнейших алгоритмов компьютерной науки этого столетия. Он нашел широкое применение в нашей повседневной жизни — от фильтров Instagram до обработки MP3-файлов.
Наиболее частой реализацией, используемой рядовым пользователем, иногда даже неосознанно, является адаптация из NumPy. Однако, несмотря на популярность, их алгоритм не является самым эффективным. С помощью нескольких простых манипуляций и статьи 2015 года мы обошли алгоритм NumPy по производительности аж на 30-60%. Основная проблема этой реализации заключается в том, что она изначально основана на слабом с точки зрения производительности алгоритме.
По своей сути алгоритм, реализуемый NumPy, является поочередным применением обычного одномерного БПФ (FFT) к двум измерениям, что очевидно не может быть оптимальным решением.
С другой стороны, в 2015 году двое российских ученых предложили свою версию алгоритма, адаптировав идею одномерного преобразования бабочки для двумерных сигналов. В этой статье мы реализовали их базовую концепцию алгоритма, дополнив ее парочкой своих идей.
Написав наивную реализацию алгоритма из статьи, мы сразу перешли к оптимизации, которая представлена ниже:
void _fft2d( /* Квадратная матрица размера N */ ) {
// базовый случай {
if (N == 1) return;
// } базовый случай
int n = N >> 1;
/* псевдокод {
...Здесь создаются 4 временных матрицы...
// X(x, y, i, j)
// x, y -- индексация по временным подматрицам
// i, j -- индексация по строкам и столбцам в каждой подматрице
_fft2d(&X(0, 0), root * root, ...);
_fft2d(&X(0, 1), root * root, ...);
_fft2d(&X(1, 0), root * root, ...);
_fft2d(&X(1, 1), root * root, ...);
} псевдокод */
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
auto x00 = X(0, 0, i, j);
auto x10 = X(1, 0, i, j) * /* W[i] */;
auto x01 = X(0, 1, i, j) * /* W[j] */;
auto x11 = X(1, 1, i, j) * /* W[i] * W[j] */;
X(0, 0, i, j) = x00 + x10 + x01 + x11;
X(0, 1, i, j) = x00 + x10 - x01 - x11;
X(1, 0, i, j) = x00 - x10 + x01 - x11;
X(1, 1, i, j) = x00 - x10 - x01 + x11;
}
}
}
Любой рекурсивный алгоритм может быть усовершенствован путем увеличения размера базового случая. Вот как к этому подошли мы:
void _fft2d( /* Квадратная матрица размера N */ ) {
// базовый случай {
if (N == 1) return;
if (N == 2) {
#define Y(y, x) (V[(y)*rowsize + (x)])
auto x00 = Y(0, 0);
auto x10 = Y(1, 0);
auto x01 = Y(0, 1);
auto x11 = Y(1, 1);
Y(0, 0) = x00 + x10 + x01 + x11;
Y(0, 1) = x00 + x10 - x01 - x11;
Y(1, 0) = x00 - x10 + x01 - x11;
Y(1, 1) = x00 - x10 - x01 + x11;
return;
}
// } базовый случай
// ...
}
Следующий логический шаг заключался в том, чтобы отказаться от создания четырех ненужных временных подматриц на каждом рекурсивном шаге в пользу всего лишь одной. Для этого мы использовали концепцию из статьи algorithmica, модифицировав ее для двумерной матрицы. Данная возможность также позволяет сократить ненужные выделения памяти и увеличить количество обращений к кэшу.
// Вычисление значений для rev_bits[n]
auto revbits = [](size_t *v, size_t n) {
int lg_n = log2(n);
forn(i, n) {
int revi = 0;
forn(l, lg_n) revi |= ((i >> l) & 1) << (lg_n - l - 1);
v[i] = revi;
}
};
size_t *rev_n = new size_t[N], *rev_m = new size_t[M];
revbits(rev_n, N), revbits(rev_m, M);
// Преобразующая матрица
forn(i, N) {
int rev_i = rev_n[i];
forn(j, M) {
if ((i < rev_i) || ((i == rev_i) && (j < rev_m[j])))
std::swap(V[i * M + j], V[rev_i * M + rev_m[j]]);
}
}
Дальнейшей нашей задачей было предварительное вычисление комплексных корней из единицы:
int mxdim = std::max(N, M);
const int lg_dim = log2(mxdim);
auto W = new fft_type[mxdim];
auto rooti = std::polar(1., (inverse ? 2 : -2) * fft::pi / mxdim);
// Вычисление матрицы поиска корней
auto cur_root = rooti;
W[0] = 1;
forn (i, mxdim - 1) W[i + 1] = W[i] * cur_root;
Как нам может быть достаточно такого массива, спросите вы? Отметим, что в наивной реализации на начальном шаге рекурсии мы передаем массив степеней корня. На следующем уровне рекурсии мы также передаем квадрат этого корня(W[2]). На следующем уровне рекурсии мы проходим через тот же массив степеней, но с шагом 2. Из этого наблюдения можно сделать вывод, что на i-м уровне рекурсии шаг, с которым мы будем проходить через массив W, равен 2ⁱ.
На этом этапе у нас получился следующий код:
void _fft2d(
fft_type *__restrict__ V,
size_t N,
size_t rowsize,
fft_type *__restrict__ W,
int step
) {
// базовый случай {
if (N == 1) return;
if (N == 2) {
#define Y(y, x) (V[(y)*rowsize + (x)])
auto x00 = Y(0, 0);
auto x10 = Y(1, 0);
auto x01 = Y(0, 1);
auto x11 = Y(1, 1);
Y(0, 0) = x00 + x10 + x01 + x11;
Y(0, 1) = x00 + x10 - x01 - x11;
Y(1, 0) = x00 - x10 + x01 - x11;
Y(1, 1) = x00 - x10 - x01 + x11;
return;
}
// } базовый случай
int n = N >> 1;
#define X(y, x, i, j) (V[((y)*n + (i)) * rowsize + ((x)*n) + j])
#define params n, rowsize, W, (step << 1)
_fft2d(&X(0, 0, 0, 0), params);
_fft2d(&X(0, 1, 0, 0), params);
_fft2d(&X(1, 0, 0, 0), params);
_fft2d(&X(1, 1, 0, 0), params);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
auto x00 = X(0, 0, i, j);
auto x10 = X(1, 0, i, j) * W[step * i];
auto x01 = X(0, 1, i, j) * W[step * j];
auto x11 = X(1, 1, i, j) * W[step * (i + j)];
X(0, 0, i, j) = x00 + x10 + x01 + x11;
X(0, 1, i, j) = x00 + x10 - x01 - x11;
X(1, 0, i, j) = x00 - x10 + x01 - x11;
X(1, 1, i, j) = x00 - x10 - x01 + x11;
}
}
}
Оригинальный алгоритм имеет еще один очевидный недостаток — он работает только с квадратными матрицами с размерностью, равной степени двойки. С помощью нескольких простых модификаций, мы можем распространить его на прямоугольные матрицы:
void _plan(
fft_type *__restrict__ V,
size_t N,
size_t M,
size_t rowsize,
fft_type *__restrict__ W,
int step_i,
int step_j
) {
// Вычисление квадратной матрицы
if (N == M) _fft2d(V, N, rowsize, W, step_i);
// Выполнение вертикального разбиения
else if (N > M) {
int n = N >> 1;
#define Y(y, i, j) (V[((y)*n + (i)) * rowsize + j])
#define params n, M, rowsize, W, (step_i << 1), step_j
_plan(&Y(0, 0, 0), params);
_plan(&Y(1, 0, 0), params);
forn (i, n) {
forn (j, M) {
auto y00 = Y(0, i, j);
auto y10 = Y(1, i, j) * W[i * step_i];
Y(0, i, j) = y00 + y10;
Y(1, i, j) = y00 - y10;
}
}
// Выполнение горизонтального разбиения
} else { /* ...Аналогичный подход... */ }
}
Важно отметить, что NumPy в своем алгоритме делает дополнительное выделение памяти под БПФ, доводя типы в нем до np.complex128; если избежать этого шага, то можно получить выигрыш примерно в 10%. Также мы реализовали многопоточность.
В качестве наглядной демонстрации выигрыша в производительности мы можем представить таблицу с временем выполнения, а также график, показывающий эффективность нашей работы в сравнении с NumPy:
Размер матрицы |
Время работы Numpy (с) |
Наше время (с) |
Без дополнительного выделения (s) |
Многопоточность (с) |
2048x512 |
0.0116 |
0.0093 |
0.0090 |
0.0044 |
2048x1024 |
0.0301 |
0.0217 |
0.0186 |
0.0068 |
2048x2048 |
0.0610 |
0.0457 |
0.0401 |
0.0127 |
4096x1024 |
0.0680 |
0.0455 |
0.0396 |
0.0173 |
4096x2048 |
0.1306 |
0.0958 |
0.0810 |
0.0299 |
4096x4096 |
0.2669 |
0.1970 |
0.1730 |
0.0539 |
8192x2048 |
0.2891 |
0.2010 |
0.1728 |
0.0687 |
8192x4096 |
0.5679 |
0.4109 |
0.3493 |
0.1254 |
8192x8192 |
1.3753 |
0.9414 |
0.8273 |
0.2651 |
Таблица с результатами
Заключение
Модифицированный алгоритм российских математиков по эффективности обгоняет "строки и столбцы" под капотом NumPy. Несколько логически закономерных манипуляций, таких как увеличение базового случая, значительно повысили нашу производительность.
Очень важно отметить, что те действия, которые мы выполняли при реализации этого алгоритма, могут быть использованы и в случае других алгоритмов, что может пригодиться вам в будущем. Стоит также сказать, что, хотя мы уже приложили значительные усилия, реализация все еще может быть улучшена путем добавления матриц подстановки различных размеров. Цель данной статьи — поделиться исходным кодом, который может помочь улучшить вычисление преобразований в ваших проектах.
Ссылку на репозиторий можно найти ниже, также можно импортировать пакет напрямую из терминала:
pip3 install git+https://github.com/2D-FFT-Project/2d-fft
Спасибо за внимание :)
Ресурсы
Материал подготовлен в преддверии старта курса OTUS «Алгоритмы и структуры данных». В рамках курсов каждый день проходят открытые уроки по всем IT-направлениям, все темы можно посмотреть в календаре.
Комментарии (10)
Travisw
23.11.2023 10:14-2Где нормальный код? Обрывки какие-то
alexxz
23.11.2023 10:14+3В статье есть ссылка на репозиторий. https://github.com/2D-FFT-Project/2d-fft
MartinaRM
23.11.2023 10:14-13Как вы взяли в преподаватели Лаврова Дениса? Чему он может научить? Как бросить жену с маленькими детьми и грудным малышом? Как гулять? Как врать всем и вся? Он же сам еще ребенок. Без чести и достоинства. У него была идеальная, красивая и мудрая жена. Он ведет себя позорно. Сколько можно слушать его. Скандальный тип и неприятный. Ругается со всеми. В Сбере ругается. В ключавто штаны просиживал. Изворачивается вечно.
Как преподаватель полный ноль. Речь ужасная. Непонятно, что говорит.alexxz
23.11.2023 10:14+8Судя по времени регистрации и эмоциональности отзыва вы очень хорошо знаете этого человека. Крайне вероятно, что вы на него очень сильно обижены. По формулировкам предположу, что вы и есть бывшая жена. Какое отношение ваши личные дрязги имеют к теме статьи?
ptr128
23.11.2023 10:14+2Скорее все же бывшая тёща.
ksbes
23.11.2023 10:14+2Это она ещё биографию Ландау не читала в изложении его бывшей жены! Морально-политические качества с профессиональными абсолютно скрещиающиеся
DustCn
Шел 2023 год, люди продолжают писать FFT-велосипеды.
Насколько ваша реализация быстрее чем классическая FFTW или MKL?
UFO_01
Как по мне, если и есть смысл писать такие велосипеды, то только на чистом С для микроконтроллеров, если не устраивает либа CMSIS DSP, конечно же