Почему умножение матриц 4x4 в Eigen происходит более чем в два раза быстрее, чем 3x3?

Я сравнил производительность умножения матриц 3x3 и 4x4 с использованием Eigen с флагом оптимизации -O3 и, к моему удивлению, обнаружил, что случай 4x4 более чем в два раза быстрее, чем случай 3x3! Этот результат был неожиданным, и мне любопытно понять, почему.

Тестовый код, который я использовал, был скомпилирован с помощью следующей команды: g++ -O3 -I/usr/include/eigen3 tmp.cpp . Вот фрагмент кода для теста:

#include <Eigen/Dense>
#include <Eigen/Geometry>
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <valgrind/callgrind.h>

void benchmark_matrix3d_multiplication(const std::vector<Eigen::Matrix3d>& matrices) {
    auto start = std::chrono::high_resolution_clock::now();
    Eigen::Matrix3d result = Eigen::Matrix3d::Identity();
    for(size_t i = 0; i < matrices.size(); i++) {
        asm("# 3dmat mult start");
        result = result * matrices[i];
        asm("# 3dmat mult end");
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    std::cout << "Matrix3d: " << duration.count() << " microseconds" << std::endl;
    std::cout << result << std::endl;  // to avoid compiler optimization
}

void benchmark_matrix4d_multiplication(const std::vector<Eigen::Matrix4d>& matrices4) {
    auto start = std::chrono::high_resolution_clock::now();
    Eigen::Matrix4d result = Eigen::Matrix4d::Identity();
    for(size_t i = 0; i < matrices4.size(); i++) {
        asm("# 4dmat mult start");
        result = result * matrices4[i];
        asm("# 4dmat mult end");
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    std::cout << "Matrix4d: " << duration.count() << " microseconds" << std::endl;
    std::cout << result << std::endl;  // to avoid compiler optimization
}

void benchmark_matrix_multiplication() {
    const int num_matrices = 100000;

    std::vector<Eigen::Matrix3d> matrices(num_matrices);
    for(size_t i = 0; i < num_matrices; i++) {
        auto q = Eigen::Quaterniond::UnitRandom();
        matrices[i] = q.toRotationMatrix();
    }
    std::vector<Eigen::Matrix4d> matrices4(num_matrices);
    for(size_t i = 0; i < num_matrices; i++) {
        matrices4[i].block<3,3>(0,0) = matrices[i];
    }

    CALLGRIND_START_INSTRUMENTATION; // start callgrind
    benchmark_matrix3d_multiplication(matrices);
    benchmark_matrix4d_multiplication(matrices4);
}

int main() {
    benchmark_matrix_multiplication();
}

Результат следующий и подтверждает, что эти два результата совпадают.

Matrix3d: 2008 microseconds
   0.440386   -0.897765 -0.00889435
   0.808307    0.400777   -0.431298
   0.390768    0.182748    0.902166
Matrix4d: 833 microseconds
   0.440386   -0.897765 -0.00889435           0
   0.808307    0.400777   -0.431298           0
   0.390768    0.182748    0.902166           0
          0           0           0           0

Обратите внимание, что я добавил asm("# 3dmat mult start") и подобные маркеры для обозначения соответствующих разделов ассемблерного кода. Судя по результатам сборки, кажется, что операции умножения действительно выполняются, а не оптимизируются компилятором. Интересно, что ассемблерный код для случая 4x4 содержит больше строк инструкций, чем для случая 3x3, но выполняется быстрее.

Может ли кто-нибудь объяснить, почему умножение матрицы 4x4 в этом сценарии происходит значительно быстрее? Есть ли ошибка в моих измерениях или есть какая-то основная причина такого результата?

Дополнительная информация

Компьютеры предпочитают работать со степенью 2. Количество строк не является важным показателем; более важно, сколько из них будет казнено и сколько времени займет каждое из них.

Scott Hunter 26.08.2024 18:33

Моя дикая догадка: 8 * 4 * 4 = 128 :), 8 * 3 * 3 = 72 :(. Векторизация лучше для случая 4*4

Raildex 26.08.2024 18:36

Этот вид математики используется так часто (например, в 3D-компьютерной графике/4D-гомогонизированных координатах, умножении матриц искусственного интеллекта), что процессорам даны специальные инструкции для быстрого выполнения этих операций. Поиск инструкций SIMD/векторизации. Прирост производительности зависит от фактического используемого процессора.

Pepijn Kramer 26.08.2024 18:42

Это единственная итерация? Что, если процессор увеличится после первого теста? А как насчет измерения циклов вместо времени?

huseyin tugrul buyukisik 26.08.2024 20:12

Вы не выполнили компиляцию с включенными современными функциями ЦП. Скомпилируйте с помощью -march=native или -march=x86-64-v3 (для AVX2 и FMA), если вам нужна производительность. В любом случае, три значения — это всегда проблема, потому что это не полный векторный регистр, поэтому вы не всегда можете загрузить и сохранить его, например, в одной инструкции. Это также означает, что начало столбца не выровнено, что требует использования инструкций невыровненной векторной загрузки. Проблема, особенно для SSE2.

Homer512 26.08.2024 21:33

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

Hamza Mehboob 27.08.2024 13:47

Привет!? Ты еще здесь, Хирольшида? Собираетесь ли вы ответить на комментарии выше?

Mike Nakis 28.08.2024 15:29

@MikeNakis Да, я все еще здесь. Что касается -march=native, то это было первое, что я попробовал, и первоначальная мотивация этого эксперимента заключалась в том, что с -avx, возможно, 4x4 будет быстрее, чем 3x3, и это было правдой. Но мое замешательство больше связано с тем, почему это происходит только с использованием SSE. Что касается «разгона процессора», я поменял местами два теста, но результат был тот же. По причине «симметричной матрицы» я подозреваю, что компилятор не так умен, потому что все симметричные матрицы хранятся вне функции, но попробовать стоит.

HiroIshida 29.08.2024 03:37
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
10
8
340
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

TL;DR: одновременно происходит несколько проблем, но главная из них заключается в том, что 3D-версия имеет более высокую задержку (из-за проблем с планированием и файлом регистров), которую нельзя перекрыть с вычислениями из-за зависимости, переносимой в цикле. Изменение порядка операций помогает ЦП скрыть задержку 3D-версии, поэтому она может быть быстрее, чем 4D. Другие проблемы, влияющие на стабильность теста, связаны с масштабированием частоты (и, в более общем плане, управлением питанием) и выравниванием памяти.


Установка хорошего эталона

Я могу воспроизвести этот процессор AMD Ryzen 7 5700U (Zen2) с GCC 13.2.0 на Ubuntu 24.04. Результаты производительности за один прогон очень нестабильны (поскольку бенчмарк слишком короткий). При этом при многократном запуске программы (например, 1000 раз) начинают происходить интересные вещи и неожиданно появляется странный эффект!

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

Обратите внимание, что вертикальная ось — это сообщаемое время (в мкс), а горизонтальная ось — количество итераций.

Мы видим, что 3D-версия менее стабильна, чем 4D-версия, но шум настолько велик, что мы не можем ничего сделать (может просто 3D менее стабильно, чем 4D и есть какой-то странный порог в таймингах) 3D-версии).

Если увеличить количество запусков до 1000, то получим удивительные результаты:

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

Теперь мы можем сказать, что 3D-версия действительно в среднем медленнее и менее стабильна, чем 4D-версия. Интересно, что мы видим, что 4D-версия через определенное время работает на удивление быстрее, чем 3D-версия.


Несвязанные параметры

Обратите внимание, что использование -march=native ускоряет вычисления (благодаря AVX/AVX2), но не влияет на проблемы в целом. Также обратите внимание, что -march=native и -mavx2 -mfma дают одинаковые результаты производительности.

Обратите внимание, что аналогичный эффект можно увидеть и с векторами, в 20 раз большими. Эффект также не зависит от привязки потока, поэтому это определенно не связано с переключением контекста или эффектами NUMA. Изменение порядка вызова функций не привело к существенному влиянию на производительность. Регулятор масштабирования установлен на performance на всех ядрах (хотя частота может быть переменной). Может ли это быть из-за масштабирования частоты?


Влияние частоты (масштабирования) и управления питанием

Что ж, похоже, масштабирование частоты каким-то образом играет роль в начальном пороге для 4D-версии. Я установил минимальную и максимальную частоту на одно и то же значение (800 МГц на моей машине из-за ошибки в регуляторе масштабирования на моей машине, который не заботится о минимальной частоте, которую я действительно могу установить, поэтому минимальная частота имеет тенденцию всегда составлять 800 МГц для активные ядра). Проверил, чтобы частота была стабильной во время прогона (по крайней мере, с грубой детализацией). Вот результат:

Мы видим, что для 4D-версии порога больше нет! В более общем плане я заметил два эффекта при попытке использовать разные частоты:

  • более низкая частота уменьшает разрыв между высокими таймингами (первые) и низкими таймингами (последние после порога) 4D-версии;
  • более низкая частота уменьшает пороговую задержку (т.е. количество итераций перед порогом);
  • Большее количество элементов в векторах также приводит к меньшей пороговой задержке.

Что касается двух последних пунктов, то на самом деле это связано с тем, что пороговая задержка представляет собой фиксированную пороговую задержку, независимую от частоты (около 2,5 секунды), поэтому более низкая частота делает каждую итерацию медленнее, вызывая меньшую задержку. Все это доказывает, что частота влияет на порог, хотя я не могу объяснить, почему. Я предполагаю, что это связано с управлением питанием процессоров AMD Zen. Я ожидаю, что эта константа будет либо установлена ​​в ядре (возможно, в файлах, зависящих от поставщика), либо константа оборудования/прошивки, основанная на частоте вне ядра (независимо от частоты ядра).

Я также обнаружил (недавно), что эффекта не происходит, когда ноутбук заряжается, хотя производительность немного менее стабильна! Это подтверждает, что эта проблема напрямую связана с управлением питанием ЦП.


Влияние выравнивания памяти

Когда время выполнения функции, выполняющей базовые числовые вычисления, квантуется (и эффект не связан с частотой), квантование часто вызывается проблемами выравнивания памяти. Это то, что происходит с 3D-версией, обычно потому, что матрица двойной точности 3x3 занимает 3*3*8=72 байт (как указано Raildex в комментариях). На практике компиляторы, такие как GCC, могут добавлять некоторые дополнения в конце структуры, чтобы доступ к памяти обычно был быстрее (что приводит к 80-байтовым матрицам 3x3). Обратите внимание, что это не степень двойки. Более того, матрица 3x3 обычно пересекает две строки кэша, что приводит к дополнительной загрузке кэша L1 во время невыровненного доступа. Такая дополнительная нагрузка приводит к увеличению задержки. Хуже того: накладные расходы на задержку не являются постоянными, поэтому я думаю, что планировщику инструкций ЦП может быть сложнее эффективно планировать целевые инструкции. Чтобы проверить гипотезу выравнивания памяти, мы можем просто выровнять каждую матрицу по строке кэша. Это не очень эффективно с точки зрения объема памяти (поэтому может снизить производительность кодов, привязанных к памяти), но это должно, по крайней мере, решить проблему выравнивания. Вот код для принудительного выравнивания матриц в памяти:

// Align each matrix to 64 bytes (i.e. a cache line)
class alignas(64) Matrix3d : public Eigen::Matrix3d
{
public:
    Matrix3d() = default;
    Matrix3d(const Matrix3d&) = default;
    Matrix3d(Matrix3d&&) = default;
    template<typename MatrixType>
    Matrix3d(const MatrixType& other) : Eigen::Matrix3d(other) {}
    template<typename MatrixType>
    Matrix3d& operator=(const MatrixType& other) { Eigen::Matrix3d::operator=(other); return *this; }
};

class alignas(64) Matrix4d : public Eigen::Matrix4d
{
public:
    Matrix4d() = default;
    Matrix4d(const Matrix4d&) = default;
    Matrix4d(Matrix4d&&) = default;
    template<typename MatrixType>
    Matrix4d(const MatrixType& other) : Eigen::Matrix4d(other) {}
    template<typename MatrixType>
    Matrix4d& operator=(const MatrixType& other) { Eigen::Matrix4d::operator=(other); return *this; }
};

// Replace all occurrence of Eigen::Matrix by Matrix in the rest of the code
// [...]

Вот результаты производительности:

Мы видим, что тайминги 3D-версии больше не квантуются. Обратите внимание, что, к сожалению, время соответствует верхнему порогу в предыдущих тестах. Также можно отметить, что 4D-версия немного медленнее (возможно, из-за менее эффективного генерируемого кода). Как ни странно, синхронизация 4D-версии менее стабильна. Я ожидаю, что выравнивание массива, выделенного std::vector, повлияет на такое время.

В сочетании с фиксированной (низкой) частотой мы получаем следующие результаты производительности:

Результаты сейчас стабильны, но остается одна большая проблема: почему 4D-версия по-прежнему быстрее 3D-версии?


Планирование инструкций, задержка и зависимости

У ваших функций есть особое свойство: каждая итерация горячего цикла зависит от предыдущей. В результате процессору гораздо сложнее скрыть задержку инструкции. Также сложно найти достаточно инструкций для параллельного выполнения. По этой причине производительность кода должна быть очень чувствительна к задержке инструкций, а также к любым влияющим на нее накладным расходам. Я думаю, именно поэтому этот простой код вызывает так много странных эффектов, а также почему результаты настолько нестабильны.

Планирование инструкций может оказать сильное влияние на производительность. Это особенно актуально для процессора Zen2, который AFAIK использует несколько отдельных планировщиков FP (а не единый планировщик). Более того, некоторые инструкции могут быть запланированы только для определенных портов. Таким образом, если планировщик примет неправильное решение, не поместив инструкции в лучшую очередь для данной итерации, задержка может немного увеличиться (вероятно, на несколько циклов), поэтому общее выполнение будет медленнее. Несколько циклов кажутся небольшими, но несколько циклов на итерацию, когда каждая итерация длится десятки циклов, — это много. Здесь, в последнем тесте на частоте 800 МГц, мы видим, что 4D-версии требуется около 4800 мкс для выполнения 100_000 итераций. Это означает ~38 циклов/итераций (при этом необходимо выполнить около 140-150 инструкций/итераций)!

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

Use of the 4 FP SIMD ports (i.e. execution units):
  - 3D version:
       467 703 544      fpu_pipe_assignment.total0                                            
       940 583 576      fpu_pipe_assignment.total1                                            
        22 580 911      fpu_pipe_assignment.total2                                            
        10 002 224      fpu_pipe_assignment.total3  
  - 4D version:
       334 752 204      fpu_pipe_assignment.total0                                            
       280 487 188      fpu_pipe_assignment.total1                                            
       269 800 454      fpu_pipe_assignment.total2                                            
       268 277 318      fpu_pipe_assignment.total3     

В 3D-версии два порта практически не используются вообще. Из двух оставшихся портов один насыщен в два раза больше другого! Это действительно плохо. Между тем, 4D-версия работает хорошо. Это имеет тенденцию подтверждать проблемы с планированием инструкций.

Счетчики производительности оборудования также указывают причину остановки отправленной инструкции. Вот два основных источника задержек между двумя версиями:

Source of stalls (in cycles):
  - 3D version:
     7 936 000 400      cycles                                                                
     1 609 456 587      de_dis_dispatch_token_stalls1.fp_sch_rsrc_stall                                      
     3 998 358 801      de_dis_dispatch_token_stalls1.fp_reg_file_rsrc_stall                                       
  - 4D version:
     6 950 481 062      cycles                                                                
       194 523 072      de_dis_dispatch_token_stalls1.fp_sch_rsrc_stall                                      
     2 858 781 759      de_dis_dispatch_token_stalls1.fp_reg_file_rsrc_stall                                      

fp_sch_rsrc_stall укажите «циклы, в которых группа отправки действительна, но не отправляется из-за задержки токена. Задержка ресурсов планировщика FP.». Хотя fp_reg_file_rsrc_stall аналогичен, но означает «остановка ресурса файла регистра с плавающей запятой». Таким образом, в 3D-версии, хотя некоторые задержки (20%) происходят из-за проблем с планированием, основной источник задержек (50%), по-видимому, исходит из файла регистров FP. Обычно это происходит из-за отсутствия записей в файле регистров FP, но здесь это было бы странно, поскольку предполагается, что Zen2 имеет 160 регистров FP (что кажется достаточно большим для этого кода). Интересно, может ли это быть каким-то образом связано со спекулятивным выполнением инструкций FP. В версии 4D также наблюдается большое количество зависаний регистров FP (41%) и почти полное отсутствие зависаний из-за планирования. Хотя я не до конца понимаю причину, по которой так много зависаний в файлах регистров FP, последний момент подтверждает приведенную выше гипотезу.


Более быстрый/лучший код

На практике вам действительно следует избегать написания такого кода по нескольким причинам. Прежде всего, он, конечно, не является численно стабильным (из-за большого количества операций умножения матриц, выполняемых итеративно).

Более того, зависимость между итерациями обычно снижает производительность. Ключом к ускорению этих вычислений является вычисление попарного сокращения (то есть что-то вроде (A*A)*(A*A)). Это помогает ЦП выполнять несколько умножений матриц одновременно (возможно, даже параллельно), уменьшая задержки в планировании. Кроме того, попарная редукция гораздо более устойчива численно. Это улучшение делает 3D-версию быстрее по сравнению с 4D-версией! Вот результаты производительности при вычислении матриц попарно (для обеих версий):

Мы видим, что 3D-версия теперь работает быстрее, а 4D-версия не сильно затронута этим улучшением. Обратите внимание, что вычисление матриц по группе из 4 снижает производительность, поскольку сгенерированный код кажется менее эффективным для моего процессора (из-за планирования инструкций и слишком большого количества требуемых регистров).

Инструкции FMA (т. е. инструкции, объединяющие умножение и сложение) могут повысить точность вычислений, одновременно уменьшая задержку кода. Вы можете указать -mfma компилятору, чтобы сгенерировать инструкции FMA. Однако, как указывалось ранее, это не влияет на разницу между версией 3D и 4D.

Добавление заполнения в 3D-матрицу может помочь выполнить возможно меньшую (выровненную) загрузку/сохранение, избегая некоторых накладных расходов на задержку (AFAIK, хранящий данные в строке кэша, может повлиять на скорость чтения в одной и той же строке кэша, но в разных местах на некоторых процессорах) . Компиляторы, как правило, не делают этого, поскольку им необходимо доказать, что доступ безопасен и операции не вызывают каких-либо побочных эффектов для незамаскированных значений (не говоря уже о том, что маскирование может быть дорогостоящим на некоторых платформах).

Для базовых матриц 3x3 и 4x4 лучшим решением может быть написание вручную ассемблерного кода для целевой архитектуры, несмотря на множество недостатков (непростой, утомительный в написании, не очень удобный в обслуживании, непереносимый).

Другие вопросы по теме