Попытка быстрого преобразования Фурье в C ++ 2

Это продолжение быстрого преобразования Фурье C ++. В итоге я немного изменил алгоритм, чтобы уменьшить использование памяти.

#include <vector>
#include <iostream>
#include <complex>
#include <cmath>
#include <algorithm>

#define N 1048576 // Problem size, must be a power of 2
#define PI 3.14159265358979323846

typedef std::vector<std::complex<double>> complexVec;

/*
Calculates the N'th roots of unity.
*/
complexVec rootsOfUnityCalculator() {
    complexVec table;
    table.resize(N);

    for (size_t k = 0; k < N; k++) {
        table[k] = std::complex<double>(std::cos(-2.0 * PI * k / N), std::sin(-2.0 * PI * k / N));
    }

    return table;
}

/*
Permutes an input vector of size length - a power of 2 - according
to the bit reversal permutation.

TESTED: SUCCESS
*/
void bitReversal(complexVec& input) {
    std::vector<size_t> permutation;
    permutation.resize(N);

    permutation[0] = 0;
    size_t k = 2;

    // Calculating the permutation according to recursive relation
    while (k <= N) {
        for (size_t j = 0; j < k / 2; j++) {
            permutation[j] *= 2; // permutation_k(j) = 2 * permutation_(k/2)(j)
            permutation[j + k / 2] = permutation[j] + 1; // permutation_k(j + k/2) = 2 * permutation_(k/2)(j) + 1
        }
        k *= 2;
    }

    // Permute the input
    for (size_t j = 0; j < N; j++) {
        if (j < permutation[j]) {
            std::swap(input[j], input[ permutation[j] ]);
        }
    }
}

void unorderedFFT(complexVec& input, const complexVec& table) {
    size_t k = 2;

    while (k <= N) {
        for (size_t r = 0; r < N / k; r++) {
            for (size_t j = 0; j < k / 2; j++) {
                std::complex<double> plusMinus = input[r * k + j + k / 2] * table[N / k * j]; // omega_k^j = (omega_N^(N/k))^j = omega_N^(Nj/k)
                input[r * k + j + k / 2] = input[r * k + j] - plusMinus;
                input[r * k + j] += plusMinus;
            }
        }

        k *= 2;
    }
}

/*
Calculates the FFT

TESTED: SUCCESS
*/
void FFT(complexVec& input, const complexVec& table) {
    bitReversal(input);
    unorderedFFT(input, table);
}

// Prints array of N complex numbers
void printArray(complexVec& array) {
    for (size_t k = 0; k < N - 1; k++) {
        std::cout << array[k] << ", ";
    }

    std::cout << array[N - 1] << std::endl;
}

int main() {
    complexVec input;
    input.resize(N);

    // Initialize the input to all 0, 1, ..., N
    for (size_t k = 0; k < N; k++) {
        input[k] = std::complex<double>(k, 0.0);
    }

    const complexVec table = rootsOfUnityCalculator();

    FFT(input, table);
}

1 ответ
1

Результаты профилирования

Используя профилировщик в Visual Studio, на моем компьютере (Intel Haswell, 4770K) я получаю примерно такую ​​разбивку:

  • unorderedFFT 107 мс
  • rootsOfUnityCalculator 21 мс
  • bitReversal 17 мс

Так что давайте улучшим все это.

Корни единства

Корни N из единицы на комплексной плоскости — это набор равноотстоящих точек на единичной окружности. Их координаты могут быть вычислены с использованием синуса и косинуса, но альтернатива начинается с 1 + 0i и последовательно применяя к нему небольшое вращение вектора (альтернативный вид: вычисляется первый нетривиальный корень, а затем итеративно возводится в степень 0, 1, 2 .. N-1). Таким образом, необходима только одна пара sin / cos (кстати, std::polar может использоваться для замены такой пары sin / cos более простым кодом). Обратной стороной является то, что он может быть менее точным. Обычно это нормально, но это немного искажает результаты.

Например, вот так:

complexVec rootsOfUnityCalculator() {
    complexVec table;
    table.resize(N);

    auto step = std::polar(1.0, 1.0 / N);
    std::complex<double> root = 1.0;
    for (std::size_t k = 0; k < N; k++) {
        table[k] = root;
        root *= step;
    }

    return table;
}

При этом время, которое требуется, сократилось до 7,5 мс, то есть коэффициент 2,3, что, безусловно, является значительным. Это можно сделать даже быстрее, чем это сделать, развернув цикл и удалив зависимость между копиями тела цикла, например, если развернуть на 2, тогда будет отдельный root1 и root2 каждый из которых умножается на stepSquared так что их вычисления независимы (улучшение параллелизма на уровне команд). Это сократило время до 6 мс.

Перестановка с обращением битов

Это хорошо изученная перестановка, потому что она представляет практический интерес, а именно это точное приложение. Известны различные более эффективные методы, включая некоторые варианты «увеличения обратного целого числа». Если целое число i и его обратное rev доступны оба, вычисление следующих значений для них обоих возможно эффективно.

Конечно i можно просто увеличить. rev требуется дополнительная работа, но есть надежда: XOR целого числа и его преемника — это «хорошая маска», состоящая из непрерывного диапазона единиц и непрерывного диапазона нулей без «беспорядка». Это является результатом того, как при приращении перенос проходит через конечные единицы, переворачивая каждую из них, затем, наконец, первый найденный ноль также переворачивается, и перенос «поглощается» этим нулем — так что биты, перевернутые, начинают наименьший значащий бит и образуют непрерывный диапазон. Это актуально, потому что «красивую маску» можно отменить, сдвинув ее, а затем применив XOR. rev это приводит к «инкременту с инвертированием битов». Вариант этого трюка заключается в использовании команды отсчета нулевого значения, чтобы определить длину непрерывного диапазона перевернутых битов, а затем основывать маску непосредственно на этом. Пойдем с этим:

void bitReversal(complexVec& input) {
    std::size_t maskLen = std::countr_zero(unsigned(N));

    // Permute the input
    for (std::size_t j = 0, rev = 0; j < N; j++) {
        if (j < rev)
            std::swap(input[j], input[rev]);
        std::size_t maskLen = std::countr_zero(j + 1) + 1;
        rev ^= N - (N >> maskLen);
    }
}

Здесь используется очень новый std::countr_zero от <bit> заголовок, функция C ++ 20. Есть и другие способы написать это, например _tzcnt_u64. unsigned(N) немного прискорбно, но требуется для countr_zero, который отказывается работать с подписанными типами. Желательно избежать этого, сделав N константа типа size_t вместо #define-ин его, что я все равно рекомендую.

Используя этот трюк, bitReversal снизился с 17 мс до 14 мс (кроме того, память, используемая permutation вектор сохраняется). Это немного лучше, но ничего лучше, чем улучшение вычисления корней единства.

Это не лучший способ сделать это. В if соответствует плохо спрогнозированной ветви, а шаблон доступа к памяти является полуслучайным. Существуют различные полезные статьи о технике, которую я использовал здесь, и о дальнейших улучшениях, например практически эффективные методы выполнения перестановки с обратным битом в C ++ 11 на архитектуре x86-64.

Фактическое БПФ

Настоящее мясо алгоритма. Поскольку в предыдущем вопросе вы просили не беспокоиться о предложениях по различным алгоритмам, я не буду. Однако я все же могу предложить улучшение производительности: используйте SSE3. SSE2 на самом деле достаточно, но SSE3 добавляет ADDSUBPD инструкция, удобная для сложного умножения:

__m128d multiplyComplex(__m128d A, __m128d B)
{
    __m128d ARealReal = _mm_shuffle_pd(A, A, 0);
    __m128d AImagImag = _mm_shuffle_pd(A, A, 3);
    __m128d BRealImag = B;
    __m128d BImagReal = _mm_shuffle_pd(B, B, 1);
    return _mm_addsub_pd(_mm_mul_pd(ARealReal, BRealImag), _mm_mul_pd(AImagImag, BImagReal));
}

Что можно было бы использовать так:

void unorderedFFT2(complexVec& input, const complexVec& table) {
    std::size_t k = 2;

    while (k <= N) {
        for (std::size_t r = 0; r < N / k; r++) {
            for (std::size_t j = 0; j < k / 2; j++) {
                __m128d input0 = _mm_loadu_pd((double*)&input[r * k + j + k / 2]);
                __m128d input1 = _mm_loadu_pd((double*)&input[r * k + j]);
                __m128d twiddle = _mm_loadu_pd((double*)&table[N / k * j]);
                __m128d product = multiplyComplex(input0, twiddle);
                _mm_storeu_pd((double*)&input[r * k + j + k / 2], _mm_sub_pd(input1, product));
                _mm_storeu_pd((double*)&input[r * k + j], _mm_add_pd(input1, product));
            }
        }

        k *= 2;
    }
}

Приведение этих указателей выглядит устрашающе, но, согласно параграф о доступе, ориентированном на массив из std::complex. reinterpret_cast можно использовать, если вам не нравятся приведения в стиле C.

По общему признанию, использование SSE не очень удобно для новичков. Но это действительно помогает, время unorderedFFT снизился до 70 мс, что является самым большим улучшением в абсолютном выражении.

Использование таких встроенных функций требует включения соответствующего заголовка, например #include <tmmintrin.h> (или новее) в этом случае. В зависимости от компилятора может также потребоваться передача определенных параметров командной строки (GCC и Clang нуждаются в этом, а MSVC — нет).

    Добавить комментарий

    Ваш адрес email не будет опубликован. Обязательные поля помечены *