Для реализации служебного класса C++ Vector3 массив быстрее, чем структура и класс?

просто из любопытства я реализовал утилиты vector3 тремя способами: массив (с typedef), класс и структура

Это реализация массива:

typedef float newVector3[3];

namespace vec3{
    void add(const newVector3& first, const newVector3& second, newVector3& out_newVector3);
    void subtract(const newVector3& first, const newVector3& second, newVector3& out_newVector3);
    void dot(const newVector3& first, const newVector3& second, float& out_result);
    void cross(const newVector3& first, const newVector3& second, newVector3& out_newVector3);
    }

    // implementations, nothing fancy...really

     void add(const newVector3& first, const newVector3& second, newVector3& out_newVector3)

    {
        out_newVector3[0] = first[0] + second[0];
        out_newVector3[1] = first[1] + second[1];
        out_newVector3[2] = first[2] + second[2];
    }

    void subtract(const newVector3& first, const newVector3& second, newVector3& out_newVector3){
        out_newVector3[0] = first[0] - second[0];
        out_newVector3[1] = first[1] - second[1];
        out_newVector3[2] = first[2] - second[2];
    }

    void dot(const newVector3& first, const newVector3& second, float& out_result){
        out_result = first[0]*second[0] + first[1]*second[1] + first[2]*second[2];
    }

    void cross(const newVector3& first, const newVector3& second, newVector3& out_newVector3){
        out_newVector3[0] = first[0] * second[0];
        out_newVector3[1] = first[1] * second[1];
        out_newVector3[2] = first[2] * second[2];
    }
}

И реализация класса:

class Vector3{
private:
    float x;
    float y;
    float z;

public:
    // constructors
    Vector3(float new_x, float new_y, float new_z){
        x = new_x;
        y = new_y;
        z = new_z;
    }

    Vector3(const Vector3& other){
        if(&other != this){
            this->x = other.x;
            this->y = other.y;
            this->z = other.z;
        }
    }
}

Конечно, он содержит другие функции, которые обычно появляются в классе Vector3.

И, наконец, реализация структуры:

struct s_vector3{
    float x;
    float y;
    float z;

    // constructors
    s_vector3(float new_x, float new_y, float new_z){
        x = new_x;
        y = new_y;
        z = new_z;
    }

    s_vector3(const s_vector3& other){
        if(&other != this){
            this->x = other.x;
            this->y = other.y;
            this->z = other.z;
        }
    }

Опять же, я пропустил некоторые другие общие функции Vector3. Теперь я позволил всем трем из них создать 9000000 новых объектов и выполнить 9000000 раз перекрестного произведения (я написал огромный кусок данных для кэширования после завершения одного из них, чтобы кеш не помогал им).

Вот тестовый код:

const int K_OPERATION_TIME = 9000000;
const size_t bigger_than_cachesize = 20 * 1024 * 1024;

void cleanCache()
{
    // flush the cache
    long *p = new long[bigger_than_cachesize];// 20 MB
    for(int i = 0; i < bigger_than_cachesize; i++)
    {
       p[i] = rand();
    }
}

int main(){

    cleanCache();
    // first, the Vector3 struct
    std::clock_t start;
    double duration;

    start = std::clock();

    for(int i = 0; i < K_OPERATION_TIME; ++i){
        s_vector3 newVector3Struct = s_vector3(i,i,i);
        newVector3Struct = s_vector3::cross(newVector3Struct, newVector3Struct);
    }

    duration = ( std::clock() - start ) / (double) CLOCKS_PER_SEC;
    printf("The struct implementation of Vector3 takes %f seconds.\n", duration);

    cleanCache();
    // second, the Vector3 array implementation
    start = std::clock();

    for(int i = 0; i < K_OPERATION_TIME; ++i){
        newVector3 newVector3Array = {i, i, i};
        newVector3 opResult;
        vec3::cross(newVector3Array, newVector3Array, opResult);
    }

    duration = ( std::clock() - start ) / (double) CLOCKS_PER_SEC;
    printf("The array implementation of Vector3 takes %f seconds.\n", duration);

    cleanCache();
    // Third, the Vector3 class implementation
    start = std::clock();

    for(int i = 0; i < K_OPERATION_TIME; ++i){
        Vector3 newVector3Class = Vector3(i,i,i);
        newVector3Class = Vector3::cross(newVector3Class, newVector3Class);
    }

    duration = ( std::clock() - start ) / (double) CLOCKS_PER_SEC;
    printf("The class implementation of Vector3 takes %f seconds.\n", duration);


    return 0;
}

Результат потрясающий.

Реализации struct и class завершают задачу примерно за 0,23 секунды, тогда как реализация array занимает всего 0,08 секунды!

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

Так что я действительно хочу убедиться, что это то, что должно произойти? Спасибо!


person Wenyu    schedule 26.09.2017    source источник
comment
Без компилятора и флагов трудно догадаться. Но поскольку массивы представляют собой POD, их намного проще оптимизировать (например, инструкции SIMD).   -  person camelCase    schedule 26.09.2017
comment
Какие параметры компилятора вы используете?   -  person ead    schedule 26.09.2017
comment
Я также предполагаю, что все определения функций доступны в main.cpp и могут быть встроены компилятором?   -  person ead    schedule 26.09.2017
comment
Кстати, ваш перекрестный продукт для массивов - это не то, на что обычно ссылаются как на перекрестный продукт двух 3d-векторов. Так что, возможно, вы реализовали это правильно для класса/структуры и, следовательно, разницы (не видя кода, невозможно сказать).   -  person ead    schedule 26.09.2017
comment
чтобы сделать пример минимальным, я бы удалил add, subtract, dot для массивов, а чтобы сделать пример полным, добавил определения cross для класса/структуры.   -  person ead    schedule 26.09.2017


Ответы (2)


Краткий ответ: это зависит. Как видите, разница есть, если скомпилировать без оптимизации.

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

 Optimization    Times (struct vs. array)
    -O0              0.27 vs. 0.12
    -O1              0.14 vs. 0.04
    -O2              0.00 vs. 0.00
    -O3              0.00 vs. 0.00

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

При компиляции с -O2 ваш код занимает ровно 0.0 секунд для обеих версий, но это потому, что оптимизаторы видят, что эти значения вообще не используются, поэтому просто отбрасывают весь код!

Убедимся, что этого не происходит:

#include <ctime>
#include <cstdio>

const int K_OPERATION_TIME = 1000000000;

int main(){
    std::clock_t start;
    double duration;

    start = std::clock();

    double checksum=0.0;
    for(int i = 0; i < K_OPERATION_TIME; ++i){
        s_vector3 newVector3Struct = s_vector3(i,i,i);
        newVector3Struct = s_vector3::cross(newVector3Struct, newVector3Struct);
        checksum+=newVector3Struct.x +newVector3Struct.y+newVector3Struct.z; // actually using the result of cross-product!
    }

    duration = ( std::clock() - start ) / (double) CLOCKS_PER_SEC;
    printf("The struct implementation of Vector3 takes %f seconds.\n", duration);

    // second, the Vector3 array implementation
    start = std::clock();

    for(int i = 0; i < K_OPERATION_TIME; ++i){
        newVector3 newVector3Array = {i, i, i};
        newVector3 opResult;
        vec3::cross(newVector3Array, newVector3Array, opResult);
        checksum+=opResult[0] +opResult[1]+opResult[2];  // actually using the result of cross-product!
    }

    duration = ( std::clock() - start ) / (double) CLOCKS_PER_SEC;
    printf("The array implementation of Vector3 takes %f seconds.\n", duration);

    printf("Checksum: %f\n", checksum);
}

Вы увидите следующие изменения:

  1. Кэш не задействован (кэш-промахов нет), поэтому я просто удалил код, отвечающий за его сброс.
  2. Нет никакой разницы между классом и структурой из производительности (после компиляции действительно нет разницы, вся разница между синтаксическим сахаром public-private только поверхностная), поэтому я смотрю только на структуру.
  3. Результат перекрестного произведения фактически используется и не может быть оптимизирован.
  4. Теперь есть 1e9 итераций, чтобы получить значимое время.

С этим изменением мы видим следующие тайминги (компилятор Intel):

 Optimization    Times (struct vs. array)
    -O0              33.2 vs. 17.1
    -O1              19.1 vs. 7.8
    -Os              19.2 vs. 7.9
    -O2              0.7 vs. 0.7
    -O3              0.7 vs. 0.7

Я немного разочарован тем, что -Os имеет такую ​​плохую производительность, но в остальном вы можете видеть, что при оптимизации нет никакой разницы между структурами и массивами!


Лично мне -Os очень нравится, потому что он производит сборку, которую я могу понять, поэтому давайте посмотрим, почему он такой медленный.

Самое очевидное, не заглядывая в получившуюся сборку: s_vector3::cross возвращает s_vector3-объект, но мы присваиваем результат уже существующему объекту, так что если оптимизатор не увидит, что старый объект больше не используется, то он может и не быть умеет делать РВО. Так пусть заменит

newVector3Struct = s_vector3::cross(newVector3Struct, newVector3Struct);
checksum+=newVector3Struct.x +newVector3Struct.y+newVector3Struct.z;

с:

s_vector3 r = s_vector3::cross(newVector3Struct, newVector3Struct);
checksum+=r.x +r.y+r.z; 

Теперь результаты: 2.14 (struct) vs. 7.9 - это значительное улучшение!

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

person ead    schedule 26.09.2017

В данном случае нет. Что касается процессора; классы, структуры и массивы — это просто макеты памяти, и макет в этом случае идентичен. В нерелизных сборках, если вы используете встроенные методы, они могут компилироваться в настоящие функции (главным образом, чтобы помочь отладчику перейти к методам), так что может иметь небольшое воздействие.

Добавление на самом деле не хороший способ проверить производительность типа Vec3. Точечное и/или перекрестное произведение обычно являются лучшим способом тестирования.

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

то есть вместо этого:

constexpr int N = 100000;
struct Vec3 {
  float x, y, z; 
};
inline float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
void dotLots(float* dps, const Vec3 a[N], const Vec3 b[N])
{
  for(int i = 0; i < N; ++i)
    dps[i] = dot(a[i], b[i]);
}

Вы бы сделали это:

constexpr int N = 100000;
struct Vec3SOA {
  float x[N], y[N], z[N]; 
};
void dotLotsSOA(float* dps, const Vec3SOA& a, const Vec3SOA& b)
{
  for(int i = 0; i < N; ++i)
  {
    dps[i] = a.x[i]*b.x[i] + a.y[i]*b.y[i] + a.z[i]*b.z[i];
  }
}

Если вы скомпилируете с параметрами -mavx2 и -mfma, то последняя версия оптимизируется довольно хорошо.

person robthebloke    schedule 26.09.2017
comment
Приятно осознавать, что ваше решение может повысить производительность! Есть ли у вас экспериментальные данные, которые покажут, насколько быстрее может быть ваш подход? - person ead; 26.09.2017
comment
пример. В первом случае скалярное произведение вычисляется с использованием инструкций mulss и 2x fmaddss («ss» означает, что за раз выполняется одно число с плавающей запятой). Во втором случае компилятор развернул цикл для обработки 32 элементов за раз (4 x 8). Однако в этом случае используются инструкции 'ps' (которые работают с 8 числами с плавающей запятой одновременно). Так что теоретически этот код в 8 раз быстрее (или в 16 раз, если скомпилирован для AVX512). На практике этот код будет ограничен пропускной способностью памяти, потому что скалярное произведение слишком простое. - person robthebloke; 27.09.2017