Сравнение оптимизированных построений с корпусом коммутатора и полиморфизмом

У меня есть необходимость в тестировании производительности для двух решений: одна, которая использует полиморфизм для запуска типа коммутатора и один, который использует случай переключения, чтобы выбрать, какую из функций выполнять. Я действительно должен оптимизировать этот код. Я написал следующий тестовый пример (вы можете просто скопировать вставку кода, скомпилировать его с помощью g++ -std=c++14 -O3 и запустить его с помощью echo 1 | ./a.out!). Код действительно прост, если вы его прочитали!

#include <iostream>
#include <chrono>
#include <functional>
#include <array>
#include <cassert>
#include <vector>
#include <memory>
using namespace std;

struct profiler
{
    std::string name;
    std::chrono::high_resolution_clock::time_point p;
    profiler(std::string const &n) :
        name(n), p(std::chrono::high_resolution_clock::now()) { }
    ~profiler()
    {
        using dura = std::chrono::duration<double>;
        auto d = std::chrono::high_resolution_clock::now() - p;
        std::cout << name << ": "
            << std::chrono::duration_cast<dura>(d).count()
            << std::endl;
    }
};
#define PROFILE_BLOCK(pbn) profiler _pfinstance(pbn)

class Base {
public:
    virtual int increment(int in) {
        return in + 2;
    }
};
class Derived : public Base {
public:
    int increment(int in) override {
        return ++in;
    }
};

int increment_one(int in) {
    return in + 2;
}
int increment_two(int in) {
    return ++in;
}
int increment_three(int in) {
    return in + 4;
}
int increment_four(int in) {
    return in + 2;
}

static constexpr unsigned long long NUMBER_LOOP{5000000000};
int main() {

    int which_function;
    cin >> which_function;

    {
        PROFILE_BLOCK("nothing");
    }

    {
        PROFILE_BLOCK("switch case");
        auto counter = 0;
        for (unsigned long long i  = 0; i < NUMBER_LOOP; ++i) {
            switch(which_function) {
            case 0:
                counter = increment_one(counter);
                break;
            case 1:
                counter = increment_two(counter);
                break;
            case 2:
                counter = increment_three(counter);
                break;
            case 3:
                counter = increment_four(counter);
                break;
            default:
                assert(false);
                break;
            }
        }
        cout << counter << endl;
    }

    {
        PROFILE_BLOCK("polymorphism");
        auto counter = 0;
        std::unique_ptr<Base> ptr_base{new Derived()};
        for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
            counter = ptr_base->increment(counter);
        }
    }

    return 0;
}

Результат, который я получаю, когда я создаю с помощью g++ -std=c++14 -O3 и запускаю с echo 1 | ./a.out,

nothing: 1.167e-06
705032704
switch case: 4.089e-06
polymorphism: 9.299

Я не понимаю, что именно приводит к тому, что коммутационный регистр будет почти таким же быстрым, как и случай nothing. Это из-за inlining? Это потому, что компилятор предварительно вычисляет значения для каждого сценария ввода и помещает их в таблицу поиска? Что заставляет коммутатор так быстро работать?

И как я могу приступить к написанию более справедливого теста производительности для этого сценария? В общем, я никогда не понимаю, быстрый ли код из-за прямого неоптимизированного перевода между кодом С++ и сборкой или же его компилятор предварительно вычисляет значение и полностью пропускает компиляцию и создает код "no-op style".

Примечание Структура profiler была скопирована непосредственно с другого ответа SO и не имеет отношения к вопросу, кроме того, что он измеряет время

Примечание Как указано в комментариях ниже, @dau_sama работает с тем же тестом в ящике linux с gcc вместо результатов clang в случае коммутатора, занимающего гораздо больше времени (3,34 в этом случае), но все же намного меньше, чем случай полиморфизма.

Ответ 1

Проблема с вашим кодом заключается в том, что когда вы делаете такие тесты, чтобы получить значимые результаты, вы не можете просто использовать цикл for и большое количество. Когда вы компилируете с оптимизацией -O3, компилятору разрешается вытаскивать вычисления из цикла, выполнять разворот цикла и тому подобное и вычислять во время компиляции результаты и записывать их в двоичный код. Поскольку в соответствии с правилом "как есть" вы не можете сказать разницу. Это затрудняет сравнение мелких битов кода, как это, но также и работу оптимизаторов, чтобы сделать код как можно быстрее. Если оптимизатор может видеть, что вы делаете одно и то же снова и снова, он может свернуть все вычисления вместе и победить контрольный механизм.

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

В моей модифицированной версии вашего кода я использовал два бита кода из библиотеки тестов google. Лучший способ понять, что здесь происходит, - наблюдать отличную беседу Чандлера Каррута, которая была на CppNow 2015. https://www.youtube.com/watch?v=nXaxk27zwlk

Вкратце, добавляются две встроенные директивы сборки, "DoNotOptimize" и "ClobberMemory". Это пустые блоки сборки и не приводят к фактическим инструкциям в скомпилированном коде, но они помечены как asm volatile, что сообщает оптимизатору, что у них непознаваемые побочные эффекты, и что он не должен пытаться анализировать сам сборку. Директива "memory" означает, что они потенциально могут читать/записывать все адреса памяти. Любая переменная, которая помечена как "DoNotOptimize", считается "известной" этой сборке, и поэтому, когда вызывается любая из этих функций, эта переменная эффективно "скремблируется" из рассуждений оптимизатора - хотя это пустые коллекции инструкций, необходимо предположить, что значение могло быть изменено непознаваемым образом после вызова этих функций, поэтому циклическое разворачивание и другие виды оптимизации становятся необоснованными.

Здесь моя измененная версия вашего кода и ouptut:

#include <iostream>
#include <chrono>
#include <functional>
#include <array>
#include <cassert>
#include <vector>
#include <memory>
using namespace std;

// From google benchmarks framework
// See also Chandler Carruth talk on microoptimizations and benchmarking
// https://www.youtube.com/watch?v=nXaxk27zwlk
namespace bench {

#if defined(__GNUC__)
#define BENCHMARK_ALWAYS_INLINE __attribute__((always_inline))
#else
#define BENCHMARK_ALWAYS_INLINE
#endif

template <class Tp>
inline BENCHMARK_ALWAYS_INLINE void
DoNotOptimize(Tp const & value) {
  asm volatile("" : : "g"(value) : "memory");
}

inline BENCHMARK_ALWAYS_INLINE void
ClobberMemory() {
  asm volatile("" : : : "memory");
}

} // end namespace bench

struct profiler
{
    std::string name;
    std::chrono::high_resolution_clock::time_point p;
    profiler(std::string const &n) :
        name(n), p(std::chrono::high_resolution_clock::now()) { }
    ~profiler()
    {
        using dura = std::chrono::duration<double>;
        auto d = std::chrono::high_resolution_clock::now() - p;
        std::cout << name << ": "
            << std::chrono::duration_cast<dura>(d).count()
            << std::endl;
    }
};
#define PROFILE_BLOCK(pbn) profiler _pfinstance(pbn)

class Base {
public:
    virtual int increment(int in) {
        return in + 2;
    }
};
class Derived : public Base {
public:
    int increment(int in) override {
        return ++in;
    }
};

int increment_one(int in) {
    return in + 2;
}
int increment_two(int in) {
    return ++in;
}
int increment_three(int in) {
    return in + 4;
}
int increment_four(int in) {
    return in + 2;
}

static constexpr unsigned long long NUMBER_LOOP{5000000000};
int main() {

    int which_function;
    cin >> which_function;

    {
        PROFILE_BLOCK("nothing");
    }

    {
        PROFILE_BLOCK("switch case");
        auto counter = 0;
        bench::DoNotOptimize(counter);
        for (unsigned long long i  = 0; i < NUMBER_LOOP; ++i) {
            bench::DoNotOptimize(i);
            switch(which_function) {
            case 0:
                counter = increment_one(counter);
                break;
            case 1:
                counter = increment_two(counter);
                break;
            case 2:
                counter = increment_three(counter);
                break;
            case 3:
                counter = increment_four(counter);
                break;
            default:
                assert(false);
                break;
            }
            bench::ClobberMemory();
        }
        cout << counter << endl;
    }

    {
        PROFILE_BLOCK("polymorphism");
        auto counter = 0;
        bench::DoNotOptimize(counter);
        std::unique_ptr<Base> ptr_base{new Derived()};
        for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
            bench::DoNotOptimize(i);
            counter = ptr_base->increment(counter);
            bench::ClobberMemory();
        }
    }

    return 0;
}

Вот что я получаю при запуске:

$ g++ -std=c++14 main.cpp
$ echo 1 |./a.out
nothing: 3.864e-06
705032704
switch case: 20.385
polymorphism: 91.0152
$ g++ -std=c++14 -O3 main.cpp
$ echo 1 |./a.out
nothing: 6.74e-07
705032704
switch case: 4.59485
polymorphism: 2.5395

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

Чтобы понять, в чем разница, вы можете посмотреть созданную сборку. Вы можете сделать это, используя perf, как это делает Чандлер, или использовать что-то вроде godbolt.

Здесь ссылка на godbolt gcc вашего кода. Я не читал все это, но одна вещь, которая выделяется для меня, заключается в том, что в этом разделе:

        pushq   %r13
        pushq   %r12
        leaq    16(%rdi), %r12
        pushq   %rbp
        pushq   %rbx
        subq    $24, %rsp
        testq   %rsi, %rsi
        movq    %r12, (%rdi)
        je      .L5
        movq    %rdi, %rbx
        movq    %rsi, %rdi
        movq    %rsi, %r13
        call    strlen
        cmpq    $15, %rax
        movq    %rax, %rbp
        movq    %rax, 8(%rsp)
        ja      .L16
        cmpq    $1, %rax
        je      .L17
        testq   %rax, %rax
        jne     .L18
.L9:
        movq    8(%rsp), %rax
        movq    (%rbx), %rdx
        movq    %rax, 8(%rbx)
        movb    $0, (%rdx,%rax)
        addq    $24, %rsp
        popq    %rbx
        popq    %rbp
        popq    %r12
        popq    %r13
        ret
.L16:
        leaq    8(%rsp), %rsi
        xorl    %edx, %edx
        movq    %rbx, %rdi
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_create(unsigned long&, unsigned long)
        movq    8(%rsp), %rdx
        movq    %rax, (%rbx)
        movq    %rax, %rdi
        movq    %rdx, 16(%rbx)
.L7:
        movq    %rbp, %rdx
        movq    %r13, %rsi
        call    memcpy
        jmp     .L9
.L17:
        movzbl  0(%r13), %eax
        movb    %al, 16(%rbx)
        jmp     .L9
.L5:
        movl    $.LC3, %edi
        call    std::__throw_logic_error(char const*)
.L18:

У вас есть следующие последовательные директивы перехода: ja .L16, je .L17, jne .L18. Поэтому я думаю, что ваш оператор switch, вероятно. Но когда вы смотрите на то, куда возвращаются эти утверждения, все они возвращаются к .L9, который не возвращается через оператор switch. Так что я подозреваю, что оптимизатор делает, это поднять switch за пределы вашего цикла, что позволяет ему легко вычислить выходной результат цикла для каждого возможного ввода и заставляет тест работать в нулевое время.

С другой стороны, когда я смотрю на сгенерированную сборку для моей версии, она все еще имеет те же самые теги .L16, .L17 и .L18, и все они переходят на .L9. Итак... Я не уверен, что это значит. Но, надеюсь, это поможет вам разобраться.


Edit:

В ответ на комментарий, сделанный @Holt, я скорректировал ваш код, чтобы сделать случай virtual более подходящим для случая switch, так что есть четыре производных класса и абстрактный базовый класс. Это дает мне больше результатов, чем я ожидал. Лучшее объяснение, которое я могу дать, это то, что, возможно, когда есть только один производный класс, компилятор способен выполнять "девиртуализацию" или что-то в этом роде. Современные версии gcc будут выполнять оптимизацию времени соединения, когда -O3 передается, например.

Результаты:

$ g++ -std=c++14 -O3 main.cpp
$ echo 1|./a.out 
nothing: 4.92e-07
705032704
switch case: 4.56484
polymorphism: 9.16065
$ echo 2|./a.out 
nothing: 6.25e-07
-1474836480
switch case: 5.31955
polymorphism: 9.22714
$ echo 3|./a.out 
nothing: 5.42e-07
1410065408
switch case: 3.91608
polymorphism: 9.17771

Скорректированный код:

#include <iostream>
#include <chrono>
#include <functional>
#include <array>
#include <cassert>
#include <vector>
#include <memory>
using namespace std;

// From google benchmarks framework
// See also Chandler Carruth talk on microoptimizations and benchmarking
// https://www.youtube.com/watch?v=nXaxk27zwlk
namespace bench {

#if defined(__GNUC__)
#define BENCHMARK_ALWAYS_INLINE __attribute__((always_inline))
#else
#define BENCHMARK_ALWAYS_INLINE
#endif

template <class Tp>
inline BENCHMARK_ALWAYS_INLINE void
DoNotOptimize(Tp const & value) {
  asm volatile("" : : "g"(value) : "memory");
}

inline BENCHMARK_ALWAYS_INLINE void
ClobberMemory() {
  asm volatile("" : : : "memory");
}

} // end namespace bench

struct profiler
{
    std::string name;
    std::chrono::high_resolution_clock::time_point p;
    profiler(std::string const &n) :
        name(n), p(std::chrono::high_resolution_clock::now()) { }
    ~profiler()
    {
        using dura = std::chrono::duration<double>;
        auto d = std::chrono::high_resolution_clock::now() - p;
        std::cout << name << ": "
            << std::chrono::duration_cast<dura>(d).count()
            << std::endl;
    }
};
#define PROFILE_BLOCK(pbn) profiler _pfinstance(pbn)

int increment_one(int in) {
    return in + 2;
}
int increment_two(int in) {
    return ++in;
}
int increment_three(int in) {
    return in + 4;
}
int increment_four(int in) {
    return in + 2;
}

class Base {
public:
    virtual int increment(int in) = 0;
};

class Derived1 : public Base {
public:
    int increment(int in) override {
        return increment_one(in);
    }
};

class Derived2 : public Base {
public:
    int increment(int in) override {
        return increment_two(in);
    }
};

class Derived3 : public Base {
public:
    int increment(int in) override {
        return increment_three(in);
    }
};

class Derived4 : public Base {
public:
    int increment(int in) override {
        return increment_four(in);
    }
};


static constexpr unsigned long long NUMBER_LOOP{5000000000};
int main() {

    int which_function;
    cin >> which_function;

    {
        PROFILE_BLOCK("nothing");
    }

    {
        PROFILE_BLOCK("switch case");
        auto counter = 0;
        bench::DoNotOptimize(counter);
        bench::DoNotOptimize(which_function);
        for (unsigned long long i  = 0; i < NUMBER_LOOP; ++i) {
            bench::DoNotOptimize(i);
            switch(which_function) {
            case 0:
                counter = increment_one(counter);
                break;
            case 1:
                counter = increment_two(counter);
                break;
            case 2:
                counter = increment_three(counter);
                break;
            case 3:
                counter = increment_four(counter);
                break;
            default:
                assert(false);
                break;
            }
            bench::ClobberMemory();
        }
        cout << counter << endl;
    }

    {
        PROFILE_BLOCK("polymorphism");
        auto counter = 0;
        bench::DoNotOptimize(counter);
        std::unique_ptr<Base> ptr_base;
        switch(which_function) {
        case 0:
          ptr_base.reset(new Derived1());
          break;
        case 1:
          ptr_base.reset(new Derived2());
          break;
        case 2:
          ptr_base.reset(new Derived3());
          break;
        case 3:
          ptr_base.reset(new Derived4());
          break;
        default:
          assert(false);
          break;
        }
        bench::DoNotOptimize(*ptr_base);
        for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
            bench::DoNotOptimize(i);
            counter = ptr_base->increment(counter);
            bench::ClobberMemory();
        }
    }

    return 0;
}

Ответ 2

Я получаю другой результат:
1). без оптимизации

$ g++ -std=c++11 -O0 perf.cpp 
$ ./a.out 
2
nothing: 1.761e-06
18446744072234715136
switch case: 25.1785
polymorphism: 110.119

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

2). с оптимизацией O3

$g++ -std=c++11 -O3 perf.cpp 
$ ./a.out 
2
nothing: 1.44e-07
18446744072234715136
switch case: 8.4832
polymorphism: 3.34942

ok, этот результат меня действительно удивляет, но это тоже нормально.
Функция, определенная в объявлении класса, будет встроена, и компилятор может получить адрес виртуальной функции при компиляции.

Если вы действительно хотите узнать подробности, прочитайте код сборки, возможно, вы можете использовать clang, прочитайте IR-код, который намного читабельнее, чем сборный код. просто ваш код, удалите несвязанные коды:

class Base {
public:
        virtual int increment(int in) {
        return in + 2;
        }
};

class Derived : public Base {
public:
    int increment(int in) override {
        return ++in;
    }
};

int increment_two(int in) {
    return ++in;
}

int main() {
    int which_function  = 2;
    int NUMBER_LOOP = 1;
    Base* ptr_base{new Derived()};

    for (long i  = 0; i < NUMBER_LOOP; ++i) {
            switch(which_function) {
            case 2:
                increment_two(1);
                break;
            }
    }

    for (long i = 0; i < NUMBER_LOOP; ++i) {
        ptr_base->increment(1);
    }

    return 0;
}

$g++ -std = С++ 11 -O0 = 3 code.cpp -S
  вы можете прочитать code.s

using clang:
$ clang -std=c++11 -O3  -S -emit-llvm code.cpp 
here post the clang IR code:

; ModuleID = 'xp.cpp'
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"

; Function Attrs: norecurse nounwind readnone uwtable
define i32 @_Z13increment_twoi(i32 %in) #0 {
entry:
  %inc = add nsw i32 %in, 1
  ret i32 %inc
}

; Function Attrs: norecurse uwtable
define i32 @main() #1 {
entry:
  ret i32 0
}

attributes #0 = { norecurse nounwind readnone uwtable "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+fxsr,+mmx,+sse,+sse2" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { norecurse uwtable "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+fxsr,+mmx,+sse,+sse2" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.ident = !{!0}

!0 = !{!"clang version 3.8.0 (tags/RELEASE_380/final)"}

Ответ 3

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

switch(which_function) {
    case 1: // execute increment_one NUMBER_LOOP times
    case 2: // execute increment_two NUMBER_LOOP times
    ...
}

учитывая, что ваши инкрементные функции просты, компилятор затем может встроить эти функции:

switch(which_function) {
    case 1: count += 2*NUMBER_LOOP; break;
    case 2: count += NUMBER_LOOP; break;
    ...
}

Это очень простой код и может объяснить низкое время выполнения корпуса коммутатора на вашей платформе. Создание переменной count volatile явно отключит эту оптимизацию, как отмечено в комментариях.

Конечно, единственный способ исследовать это - посмотреть на скомпилированный двоичный файл.