Использование final для уменьшения накладных расходов виртуального метода - PullRequest
0 голосов
/ 04 декабря 2018

Я сталкивался с таким вопросом о том, как ключевое слово "final" можно использовать для уменьшения накладных расходов виртуального метода ( Эффективность виртуальной функции и ключевое слово "final" ).Основываясь на этом ответе, можно ожидать, что указатель на производный класс, вызывающий переопределенные методы, помеченные как final, не столкнется с накладными расходами динамической диспетчеризации.

Чтобы оценить преимущества этого метода, я настроил несколько примеров классов и запустил егона Quick-Bench - Вот ссылка .Здесь есть 3 случая:
Случай 1 : Указатель производного класса без окончательного спецификатора:

Derived* f = new DerivedWithoutFinalSpecifier();
f->run_multiple(100); // calls an overriden method 100 times

Случай 2 : Указатель базового класса с конечным спецификатором:

Base* f = new DerivedWithFinalSpecifier();
f->run_multiple(100); // calls an overriden method 100 times

Случай 3 : указатель производного класса с конечным спецификатором:

Derived* f = new DerivedWithFinalSpecifier();
f->run_multiple(100); // calls an overriden method 100 times

Здесь функция run_multiple выглядит следующим образом:

int run_multiple(int times) specifiers {
    int sum = 0;
    for(int i = 0; i < times; i++) {
        sum += run_once();
    }
    return sum;
}

Я наблюдал следующие результаты:
По скорости: Случай 2 == Случай 3> Случай 1

Но не должен ли Случай 3 быть намного быстрее, чем Случай 2.что-то не так в моем эксперименте или в моих предположениях об ожидаемом результате?

Редактировать: Питер Кордес указал несколько действительно полезных статей для дальнейшего чтения, связанных с этой темой:
Используется ли final для оптимизации в C ++?
Почему gcc не может девиртуализировать этот вызов функции?
LTO, девиртуализация и виртуальные таблицы

1 Ответ

0 голосов
/ 04 декабря 2018

Вы правильно понимаете влияние final (за исключением, может быть, внутреннего цикла в случае 2), но ваши оценки стоимости далеко.Мы не должны ожидать большого эффекта в любом месте, потому что mt19937 просто медленный, и все 3 версии проводят в нем большую часть своего времени.


Единственное, что не потеряно / похоронено в шуме /издержки - это эффект встраивания int run_once() override final во внутренний цикл в FooPlus::run_multiple, который запускается как для случая 2, так и для случая 3.

Но случай 1 может 't встроенный Foo::run_once() в Foo::run_multiple(), поэтому во внутреннем цикле есть издержки вызова функции, в отличие от двух других случаев.

Случай 2 должен повторно вызывать run_multiple, но это только один разна 100 прогонов run_once и не имеет поддающегося измерению эффекта.


Для всех 3 случаев большинство времени тратится dist(rng);, потому что std::mt19937 довольно медленнопо сравнению с дополнительными накладными расходами не встраивая вызов функции.Внеочередное выполнение, вероятно, также может скрывать много этих накладных расходов.Но не все, так что еще есть что измерить.

Case 3 может встроить все в этот цикл asm (из вашей ссылки быстрого доступа):

 # percentages are *self* time, not including time spent in the PRNG
 # These are from QuickBench's perf report tab,
 #  presumably sample for core clock cycle perf events.
 # Take them with a grain of salt: superscalar + out-of-order exec
 #  makes it hard to blame one instruction for a clock cycle

   VirtualWithFinalCase2(benchmark::State&):   # case 3 from QuickBench link
     ... setup before the loop
     .p2align 3
    .Louter:                # do{
       xor    %ebp,%ebp          # sum = 0
       mov    $0x64,%ebx         # inner = 100
     .p2align 3  #  nopw   0x0(%rax,%rax,1)
     .Linner:                    # do {
51.82% mov    %r13,%rdi
       mov    %r15,%rsi
       mov    %r13,%rdx           # copy args from call-preserved regs
       callq  404d60              # mt PRNG for unsigned long
47.27% add    %eax,%ebp           # sum += run_once()
       add    $0xffffffff,%ebx    # --inner
       jne    .Linner            # }while(inner);
       mov    %ebp,0x4(%rsp)     # store to volatile local:  benchmark::DoNotOptimize(x);
0.91%  add    $0xffffffffffffffff,%r12   # --outer
       jne                    # } while(outer)

Case2 все еще может встроить run_once в run_multiple, потому что class FooPlus использует int run_once() override final.Во внешнем цикле есть только накладные расходы на виртуальную диспетчеризацию (только), но эта небольшая дополнительная плата за каждую итерацию внешнего цикла полностью затмевается стоимостью внутреннего цикла (идентичной для случая 2 и случая 3).

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


Случай 1 не может встроить Foo::run_once() в Foo::run_multiple(), поэтому там тоже есть служебные вызовы функций.(Тот факт, что это косвенный вызов функции, относительно незначителен; в узком цикле предсказание ветвления будет выполнять почти идеальную работу.)


Варианты 1 и 2 имеют одинаковый asm для своего внешнего цикла,если вы посмотрите на разборку в вашей ссылке Quick-Bench.

Ни один из них не может девиртуализировать и встроить run_multiple.Случай 1, потому что это виртуальный не финал, Случай 2, потому что это только базовый класс, а не производный класс с переопределением final.

        # case 2 and case 1 *outer* loops
      .loop:                 # do {
       mov    (%r15),%rax     # load vtable pointer
       mov    $0x64,%esi      # first C++ arg
       mov    %r15,%rdi       # this pointer = hidden first arg
       callq  *0x8(%rax)      # memory-indirect call through a vtable entry
       mov    %eax,0x4(%rsp)  # store the return value to a `volatile` local
       add    $0xffffffffffffffff,%rbx      
       jne    4049f0 .loop   #  } while(--i != 0);

Это, вероятно, пропущенная оптимизация: компиляторможет доказать, что Base *f взято из new FooPlus(), и, таким образом, статически известно, что он имеет тип FooPlus.operator new может быть переопределено, но компилятор по-прежнему генерирует отдельный вызов FooPlus::FooPlus() (передавая ему указатель на хранилище из new).Так что это, похоже, бросок кланга, который не использует преимущество в случае 2 и, возможно, также в случае 1.

...