Выгодно ли больше развертывать циклы в C ++ поверх массивов фиксированного размера? - PullRequest
0 голосов
/ 11 января 2019

Я хочу использовать std::array для хранения данных N-мерных векторов и реализации арифметических операций для таких векторов. Я подумал, что поскольку std::array теперь имеет функцию-член constexpr size(), я могу использовать это, чтобы развернуть циклы, которые мне нужны для арифметических операций над его элементами.

Вот минимальный пример:

#include <array> 
#include <type_traits>
#include <iostream>
#include <cassert>

template<std::size_t N=0, typename Vector>
void plus_equals(Vector& result, Vector const& input) 
{
    result[N] += input[N]; 

    if constexpr (N + 1 < result.size()) 
        plus_equals<N+1>(result, input); 
}

template<typename INT, size_t N>
class Vector
{
    std::array<INT, N> data_; 

    public: 

        template<typename ... BracketList> 
        Vector(BracketList ... blist)
        :
            data_{std::forward<BracketList>(blist)...}
        {} 

        INT& operator[](std::size_t i)
        {
            return data_[i]; 
        }

        INT operator[](std::size_t i) const 
        {
            return data_[i]; 
        }

        decltype(auto) begin() const 
        {
            return data_.begin(); 
        }

        decltype(auto) end() const 
        {
            return data_.end(); 
        }

        decltype(auto) end() 
        {
            return data_.end(); 
        }

        constexpr decltype(auto) size()
        {
            return data_.size(); 
        }

        void operator+=(Vector const& other)
        {
            plus_equals(*this, other); 
        }
};

template<size_t N = 0, typename Vector> 
Vector operator+(Vector const& uVec, Vector const& vVec)
{
    Vector result {uVec};  

    result += vVec;  

    return result;  
}

template<size_t N = 0, typename Vector> 
Vector sum(Vector const& uVec, Vector const& vVec)
{
    Vector result {uVec};  

    for (decltype(result.size()) i = 0; i < result.size(); ++i)
        result[i] += vVec[i]; 

    return result;  
}

template<typename Vector> 
void print(Vector&& v)
{
    for (const auto& el : v) std::cout << el << " ";  
    std::cout << std::endl;
}

using namespace std; 

int main()
{
    Vector<int, 3> c1 = {1,2,3}; 
    Vector<int, 3> c2 = {3,2,1}; 
    auto r1 = c1 + c2;
    print (r1);

    auto r2 = sum(c2, c2);
    print (r2); 

    Vector<int, 3> s1, s2; 

    for (std::size_t i = 0; i < 3; ++i)
        cin >> s1[i];
    for (std::size_t i = 0; i < 3; ++i)
        cin >> s2[i];

    auto r3 = s1 + s2;
    print(r3);

    auto r4 = sum(s1, s2);
    print(r4);


    return 0;
}

Операция sum реализована с использованием plus_equals, который должен развернуть отдельные операции += над элементами вектора, а функция sum(Vector const&, Vector const&) использует цикл for.

Я скомпилировал пример на godbolt , используя -O3 -std=c++2a.

Если я закомментирую все, кроме

Vector<int, 3> c1 = {2,11,7}; 
Vector<int, 3> c2 = {9,22,5}; 
auto r1 = c1 + c2;
print (r1);

Я получаю

    movabs  rax, 141733920779
    sub     rsp, 24
    lea     rdi, [rsp+4]
    mov     QWORD PTR [rsp+4], rax
    mov     DWORD PTR [rsp+12], 12
    call    void print<Vector<int, 3ul>&>(Vector<int, 3ul>&)
    xor     eax, eax
    add     rsp, 24
    ret

Что здесь происходит? Почему я не вижу первые две константы c1[0] + c2[0] и c1[1] + c2[1]? С другой стороны 7 + 5 = 12 перемещается:

    mov     DWORD PTR [rsp+12], 12

Почему сборка кода

int main()
{
    Vector<int, 3> c1 = {2,11,7}; 
    Vector<int, 3> c2 = {9,22,5}; 
    //auto r1 = c1 + c2;
    //print (r1);

    auto r2 = sum(c1, c2);
    print (r2); 

точно так же?

Если я пытаюсь использовать переменные времени выполнения:

    Vector<int, 3> s1, s2; 
    for (std::size_t i = 0; i < 3; ++i)
        cin >> s1[i];
    for (std::size_t i = 0; i < 3; ++i)
        cin >> s2[i];

    auto r3 = s1 + s2;
    print(r3);

Я получаю

    mov     edx, DWORD PTR [rsp+28]
    mov     eax, DWORD PTR [rsp+32]
    lea     rdi, [rsp+36]
    add     eax, DWORD PTR [rsp+20]
    add     edx, DWORD PTR [rsp+16]
    mov     ecx, DWORD PTR [rsp+24]
    add     ecx, DWORD PTR [rsp+12]
    mov     DWORD PTR [rsp+44], eax
    mov     DWORD PTR [rsp+36], ecx
    mov     DWORD PTR [rsp+40], edx

Который ссылается на шаблон функции plus_equals и развертывает итерации, как и ожидалось.

Для sum:

Vector<int, 3> s1, s2; 
for (std::size_t i = 0; i < 3; ++i)
    cin >> s1[i];
for (std::size_t i = 0; i < 3; ++i)
    cin >> s2[i];

//auto r3 = s1 + s2;
//print(r3);

auto r4 = sum(s1, s2);
print(r4);

Сборка:

    mov     edx, DWORD PTR [rsp+32]
    add     edx, DWORD PTR [rsp+20]
    add     ecx, eax
    shr     rax, 32
    add     eax, DWORD PTR [rsp+28]
    mov     DWORD PTR [rsp+44], edx
    mov     DWORD PTR [rsp+40], eax
    mov     DWORD PTR [rsp+36], ecx

И сравнений и переходов по равенству нет, поэтому цикл развернут.

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

Правильно ли истолковано? Если это так, можно ли сделать вывод, что нет смысла вручную развертывать итерации для массивов фиксированного размера, потому что с -O3 циклы, использующие constexpr size функции-члены, будут все равно развернуты компилятором?

1 Ответ

0 голосов
/ 12 января 2019

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

Вообще говоря, компилятор лучше выполняет микрооптимизацию, а программист лучше выполняет макрооптимизацию.

Микро-оптимизации (что МОЖЕТ сделать компилятор):

  • Развернуть петли
  • встроенные функции автоматически
  • применить оптимизацию хвостового вызова для ускорения хвостовой рекурсивной функции (многие заканчивают так же быстро, как и эквивалентный цикл)
  • elide копирует и перемещает: если вы возвращаете что-то по значению, во многих случаях компилятор может избавиться от копии или полностью ее переместить.
  • Используйте векторизованные инструкции с плавающей запятой (хотя для этого иногда требуется помощь компилятора)
  • Устранить ненужные или избыточные операторы if (например, когда вы что-то проверяете, а затем вызываете функцию-член, которая также проверяет это, когда она вставляет функцию-член, это устраняет ненужную проверку)
  • Встроенные лямбда-выражения передаются другим функциям (это происходит только в том случае, если вы не включаете их в std::function - не может быть встроено std::function)
  • Хранить локальные переменные и даже целые структуры в регистрах вместо использования RAM или Cache
  • Много математических оптимизаций

Макрооптимизации (что НЕ МОЖЕТ сделать компилятор):

Это то, на что программист все еще должен обращать внимание.

  • Изменить способ хранения данных. Если что-то не обязательно должно быть указателем, сохраните его в стеке!
  • Изменить алгоритм, используемый для расчета чего-либо. Разработка алгоритма все еще важна!
  • Другие вещи
...