Подтвердить что ты не робот

Eigen: эффект стиля кодирования на производительность

Из того, что я читал об Eigen (здесь), кажется, что operator=() действует как "барьер" сортов для ленивой оценки - например. это приводит к тому, что Eigen перестает возвращать шаблоны выражений и фактически выполняет (оптимизированное) вычисление, сохраняя результат в левую сторону =.

Это, по-видимому, означает, что один "стиль кодирования" влияет на производительность, то есть использование именованных переменных для хранения результатов промежуточных вычислений может отрицательно повлиять на производительность, заставив некоторые части вычисления вычисляться "слишком рано".

Чтобы проверить мою интуицию, я написал пример и был удивлен результатами (полный код здесь):

using ArrayXf  = Eigen::Array <float, Eigen::Dynamic, Eigen::Dynamic>;
using ArrayXcf = Eigen::Array <std::complex<float>, Eigen::Dynamic, Eigen::Dynamic>;

float test1( const MatrixXcf & mat )
{
    ArrayXcf arr  = mat.array();
    ArrayXcf conj = arr.conjugate();
    ArrayXcf magc = arr * conj;
    ArrayXf  mag  = magc.real();
    return mag.sum();
}

float test2( const MatrixXcf & mat )
{
    return ( mat.array() * mat.array().conjugate() ).real().sum();
}

float test3( const MatrixXcf & mat )
{
    ArrayXcf magc   = ( mat.array() * mat.array().conjugate() );

    ArrayXf mag     = magc.real();
    return mag.sum();
}

Приведенное выше дает 3 различных способа вычисления коэффи- циентной величины величин в комплекснозначной матрице.

  • test1 отбирает каждую часть вычисления "один шаг за раз".
  • test2 выполняет все вычисления в одном выражении.
  • test3 принимает "смешанный" подход - с некоторым количеством промежуточных переменных.

Я ожидал, что, поскольку test2 упаковывает все вычисления в одно выражение, Eigen сможет воспользоваться этим и глобально оптимизировать весь расчет, обеспечивая максимальную производительность.

Однако результаты были неожиданными (номера показаны в общей сумме микросекунд в 1000 исполнений каждого теста):

test1_us: 154994
test2_us: 365231
test3_us: 36613

(Это было скомпилировано с g++ -O3 - см. gist для получения полной информации.)

Версия, которую я ожидал быть самой быстрой (test2), была самой медленной. Кроме того, версия, которую я ожидал быть самой медленной (test1), была фактически посередине.

Итак, мои вопросы:

  • Почему test3 работает намного лучше, чем альтернативы?
  • Есть ли метод, который можно использовать (за исключением погружения в код сборки), чтобы получить представление о том, как Eigen фактически выполняет ваши вычисления?
  • Существует ли набор руководящих принципов, чтобы найти хороший компромисс между производительностью и читабельностью (использование промежуточных переменных) в коде Eigen?

В более сложных вычислениях выполнение всего в одном выражении может затруднить читаемость, поэтому я заинтересован в поиске правильного способа написания кода, который является читабельным и эффективным.

4b9b3361

Ответ 1

Это похоже на проблему GCC. Компилятор Intel дает ожидаемый результат.

$ g++ -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 200087
test2_us: 320033
test3_us: 44539

$ icpc -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 214537
test2_us: 23022
test3_us: 42099

По сравнению с версией icpc, gcc, похоже, имеет проблемы с оптимизацией вашего test2.

Для более точного результата вы можете отключить утверждения отладки -DNDEBUG, как показано здесь.

ИЗМЕНИТЬ

Для вопроса 1

@ggael дает отличный ответ, что gcc не выполняет векторный цикл суммирования. Мой эксперимент также обнаружил, что test2 работает так же быстро, как рукописный наивный for-loop, как с gcc, так и icc, что указывает на то, что причиной является векторизация, а в test2 метод, упомянутый ниже, предполагая, что Eigen правильно оценивает выражение.

Для вопроса 2

Избегание промежуточной памяти является основной целью, которую Eigen использует шаблоны выражений. Таким образом, Eigen предоставляет макрос EIGEN_RUNTIME_NO_MALLOC и простую функцию, позволяющую вам проверить, выделена ли промежуточная память при вычислении выражения. Здесь вы можете найти пример кода здесь. Обратите внимание, что это может работать только в режиме отладки.

EIGEN_RUNTIME_NO_MALLOC - если определено, вводится новый переключатель, который можно включить и выключить, вызвав set_is_malloc_allowed (bool). Если malloc не разрешается, и Eigen пытается динамически распределять память во всяком случае, результаты неудачи утверждения. Не определено по умолчанию.

Для вопроса 3

Существует способ использования промежуточных переменных и для повышения производительности, создаваемого ленивыми шаблонами оценки/выражения одновременно.

Способ использования промежуточных переменных с правильным типом данных. Вместо использования Eigen::Matrix/Array, который инструктирует выражение для оценки, вы должны использовать тип выражения Eigen::MatrixBase/ArrayBase/DenseBase, чтобы выражение буферизовалось, но не оценивалось. Это означает, что вы должны хранить выражение как промежуточное, а не результат выражения, при условии, что этот промежуточный элемент будет использоваться только один раз в следующем коде.

Поскольку определение параметров шаблона в типе выражения Eigen::MatrixBase/... может быть болезненным, вместо этого вы можете использовать auto. Вы можете найти некоторые подсказки, когда вам следует/не следует использовать auto/типы выражений в этой странице. Другая страница также рассказывает вам, как передавать выражения в виде параметров функции, не оценивая их.

Согласно поучительному эксперименту о .abs2() в ответе @ggael, я думаю, что другое руководство - избегать повторного использования колеса.

Ответ 2

Что происходит, так это то, что из-за шага .real() Eigen не будет явно векторизовать test2. Таким образом, это вызовет стандартный оператор complex:: operator *, который, к сожалению, никогда не привязан gcc. С другой стороны, другие версии используют собственную реализацию векторизованных продуктов с помощью собственных функций.

Напротив, ICC выполняет встроенный complex:: operator *, тем самым делая test2 самым быстрым для ICC. Вы также можете переписать test2 как:

return mat.array().abs2().sum();

чтобы получить еще лучшую производительность для всех компиляторов:

gcc:
test1_us: 66016
test2_us: 26654
test3_us: 34814

icpc:
test1_us: 87225
test2_us: 8274
test3_us: 44598

clang:
test1_us: 87543
test2_us: 26891
test3_us: 44617

Чрезвычайно хороший показатель ICC в этом случае обусловлен его умным механизмом автоматической векторизации.

Другим способом обхода отката gcc без изменения test2 является определение вашего собственного operator* для complex<float>. Например, добавьте следующее в начало файла:

namespace std {
  complex<float> operator*(const complex<float> &a, const complex<float> &b) {
    return complex<float>(real(a)*real(b) - imag(a)*imag(b), imag(a)*real(b) + real(a)*imag(b));
  }
}

а затем я получаю:

gcc:
test1_us: 69352
test2_us: 28171
test3_us: 36501

icpc:
test1_us: 93810
test2_us: 11350
test3_us: 51007

clang:
test1_us: 83138
test2_us: 26206
test3_us: 45224

Конечно, этот трюк не всегда рекомендуется, так как, в отличие от glib-версии, это может привести к ошибкам переполнения или численной отмены, но это все равно что icpc и другие векторизованные версии.

Ответ 3

Одна вещь, которую я сделал раньше, - это использовать ключевое слово auto. Помня о том, что большинство выражений Eigen возвращают специальные типы данных выражений (например, CwiseBinaryOp), присваивание назад может заставить выражение быть оцененным (это то, что вы видите). Использование auto позволяет компилятору выводить возвращаемый тип как любой тип выражения, который позволит избежать оценки как можно дольше:

float test1( const MatrixXcf & mat )
{
    auto arr  = mat.array();
    auto conj = arr.conjugate();
    auto magc = arr * conj;
    auto mag  = magc.real();
    return mag.sum();
}

Это должно быть ближе к вашему второму тестовому сценарию. В некоторых случаях у меня были хорошие улучшения производительности, сохраняя читаемость (вы не хотите описывать типы шаблонов выражений). Конечно, ваш пробег может отличаться, поэтому внимательно следите за ним:)

Ответ 4

Я просто хочу, чтобы вы заметили, что вы делали профилирование неоптимальным способом, поэтому на самом деле проблема может быть просто методом профилирования.

Поскольку существует много вещей, таких как локальность кеша, чтобы учесть это, вы должны выполнить профилирование таким образом:

int warmUpCycles = 100;
int profileCycles = 1000;

// TEST 1
for(int i=0; i<warmUpCycles ; i++)
      doTest1();

auto tick = std::chrono::steady_clock::now();
for(int i=0; i<profileCycles ; i++)
      doTest1();  
auto tock = std::chrono::steady_clock::now();
test1_us = (std::chrono::duration_cast<std::chrono::microseconds>(tock-tick)).count(); 

// TEST 2


// TEST 3

Как только вы сделали тест правильно, вы можете прийти к выводам.

Я очень подозреваю, что, поскольку вы профилируете одну операцию за раз, вы заканчиваете использование кешированной версии в третьем тесте, так как операции, вероятно, будут переупорядочены компилятором.

Также вы должны попробовать разные компиляторы, чтобы увидеть, является ли проблема разворачиванием шаблонов (существует ограничение на глубину для оптимизации шаблонов: возможно, вы можете поразить его одним большим выражением).

Также, если Eigen поддерживает перемещение семантики, нет причин, по которым одна версия должна быть быстрее, поскольку не всегда гарантируется, что выражения могут быть оптимизированы.

Пожалуйста, попробуйте и дайте мне знать, это интересно. Также обязательно включите оптимизацию с флагами, такими как -O3, профилирование без оптимизации не имеет смысла.

Как предотвратить компилятор, все оптимизируйте, используйте начальный ввод из файла или cin, а затем повторно подайте вход внутри функций.