Ответ 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;
}