Можно ли избежать накладных расходов vtable с помощью static_cast?

Вот моя проблема. У меня есть базовый класс и производный класс, который переопределяет некоторые методы из базового класса. Для простоты рассмотрим следующий пример:

struct base
{
  virtual void fn()
  {/*base definition here*/}
};

struct derived : base 
{
  void fn()
  {/*derived definition here*/}
};

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

void call_fn(base& obj)
{obj.fn();}

и вызов соответствующей функции будет разрешен во время во время выполнения благодаря виртуальным функциям.

Однако я беспокоюсь, что если call_fn будет вызываться миллион раз (что в моем случае будет, поскольку мое фактическое приложение является экспериментом по моделированию), я получу значительные накладные расходы, которых я хотел бы избежать.

Итак, мне было интересно, может ли использование static_cast действительно решить проблему. Может быть, что-то вроде этого:

template <typename T>
void call_fn(base& obj)
{(static_cast<T*>(&obj))->fn();}

В этом случае вызов функции будет выполнен как call_fn<base>(obj) для вызова базового метода или call_fn<derived>(obj) для вызова производного метода.

Избежит ли это решение накладных расходов на vtable или оно все равно будет затронуто? Заранее спасибо за любые ответы!

Кстати, я знаю о CRTP, но не очень хорошо с ним знаком. Вот почему я хотел бы сначала узнать ответ на этот простой вопрос :)


person linuxfever    schedule 14.01.2013    source источник
comment
Каково ваше определение значительных накладных расходов? Вы можете быть удивлены тем, каковы накладные расходы на вызов виртуальной функции несколько миллионов раз.   -  person David Rodríguez - dribeas    schedule 15.01.2013
comment
Вы действительно доказали, что накладные расходы vtable являются проблемой вашего кода? И если да, можете ли вы показать, что выполнение релевантного if (является ли этот класс) do this else do that выполняется быстрее? Я подозреваю, что это не так, если только компилятор не встраивает функцию, а это экономит много усилий.   -  person Mats Petersson    schedule 15.01.2013
comment
Я не доказал это ... поэтому я хотел сначала создать метод, который избегает vtable, чтобы я мог сравнить их и иметь более четкое представление о том, насколько значительными могут быть эти накладные расходы :)   -  person linuxfever    schedule 15.01.2013
comment
@linuxfever: стоимость динамической отправки примерно равна дополнительной косвенности. т.е. для вызова нединамической функции компилятор введет переход (call) на фиксированный адрес. В случае виртуальной функции она будет выполнять косвенный вызов через виртуальную таблицу. Многие процессоры имеют специальные инструкции для вычисления адреса (lea и подобные...). Если ваши функции в основном пусты, стоимость фактического тела функции будет определять общую стоимость операции.   -  person David Rodríguez - dribeas    schedule 15.01.2013


Ответы (4)


Избежит ли это решение накладных расходов на vtable или оно все равно будет затронуто?

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

static_cast<T&>(obj).T::fn();

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

person David Rodríguez - dribeas    schedule 14.01.2013
comment
+1 это ответ. Это действующий метод, ЕСЛИ вы полностью уверены, что слишком много ЦП — это небольшая виртуальная функция в массивном цикле, и вы не можете это исправить. Он превращает for (...) { (vtbl->foo)(); } в if (a) { for (...) A::foo(); } else { for (...) B::foo(); }. Вряд ли будет большая разница. - person doug65536; 15.01.2013
comment
@linuxfever Если это не замкнутый цикл вокруг виртуальной функции из 1 строки или около того, то накладные расходы на вызов vtbl будут незначительными. Хорошая вещь о узких петлях заключается в том, что предсказание ветвлений работает на непрямых вызовах на современных (лучше, чем примерно 1997 AMD K6) процессорах. Предполагая, что ваш компилятор не генерирует слишком много кода, работающего с передачей аргументов, он будет быстрым. C++ использует vtbl, потому что это самый быстрый способ вызвать что-то неизвестное. - person doug65536; 15.01.2013
comment
@linuxfever С другой стороны, компилятор может поднять массу общих подвыражений из цикла, если он встроит прямой (невиртуальный) вызов. - person doug65536; 15.01.2013

На самом деле это не ответ на ваш реальный вопрос, но мне было любопытно, «что на самом деле является накладными расходами на вызов виртуальной функции по сравнению с вызовом обычной функции класса». Чтобы сделать это «справедливым», я создал class.cpp, который реализует очень простую функцию, но это отдельный файл, который компилируется вне «основного».

классы.ч:

#ifndef CLASSES_H
#define CLASSES_H

class base
{
    virtual int vfunc(int x) = 0;
};

class vclass : public base
{
public:
    int vfunc(int x);
};


class nvclass
{
public:
    int nvfunc(int x);
};


nvclass *nvfactory();
vclass* vfactory();


#endif

классы.cpp:

#include "classes.h"

int vclass:: vfunc(int x)
{
    return x+1;
}


int nvclass::nvfunc(int x)
{
    return x+1;
}

nvclass *nvfactory()
{
    return new nvclass;
}

vclass* vfactory()
{
    return new vclass;
}

Это вызывается из:

#include <cstdio>
#include <cstdlib>
#include "classes.h"

#if 0
#define ASSERT(x) do { if(!(x)) { assert_fail( __FILE__, __LINE__, #x); } } while(0)
static void assert_fail(const char* file, int line, const char *cond)
{
    fprintf(stderr, "ASSERT failed at %s:%d condition: %s \n",  file, line, cond); 
    exit(1);
}
#else
#define ASSERT(x) (void)(x)
#endif

#define SIZE 10000000

static __inline__ unsigned long long rdtsc(void)
{
    unsigned hi, lo;
    __asm__ __volatile__ ("rdtsc" : "=a"(lo), "=d"(hi));
    return ( (unsigned long long)lo)|( ((unsigned long long)hi)<<32 );
}


void print_avg(const char *str, const int *diff, int size)
{
    int i;
    long sum = 0;
    for(i = 0; i < size; i++)
    {
    int t = diff[i];
    sum += t;
    }

    printf("%s average =%f clocks\n", str, (double)sum / size);
}


int diff[SIZE]; 

int main()
{
    unsigned long long a, b;
    int i;
    int sum = 0;
    int x;

    vclass *v = vfactory();
    nvclass *nv = nvfactory();


    for(i = 0; i < SIZE; i++)
    {
    a = rdtsc();

    x = 16;
    sum+=x;
    b = rdtsc();

    diff[i] = (int)(b - a);
    }

    print_avg("Emtpy", diff, SIZE);


    for(i = 0; i < SIZE; i++)
    {
    a = rdtsc();

    x = 0;
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    x = v->vfunc(x);
    ASSERT(x == 4); 
    sum+=x;
    b = rdtsc();

    diff[i] = (int)(b - a);
    }

    print_avg("Virtual", diff, SIZE);

    for(i = 0; i < SIZE; i++)
    {
    a = rdtsc();
    x = 0;
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    x = nv->nvfunc(x);
    ASSERT(x == 4);     
    sum+=x;
    b = rdtsc();
    diff[i] = (int)(b - a);
    }
    print_avg("no virtual", diff, SIZE);

    printf("sum=%d\n", sum);

    delete v;
    delete nv;

    return 0;
}

РЕАЛЬНАЯ разница в коде заключается в следующем: виртуальный вызов:

40066b: ff 10                   callq  *(%rax)

не виртуальный вызов:

4006d3: e8 78 01 00 00          callq  400850 <_ZN7nvclass6nvfuncEi>

И результаты:

Emtpy average =78.686081 clocks
Virtual average =144.732567 clocks
no virtual average =122.781466 clocks
sum=480000000

Помните, что это накладные расходы для 16 вызовов на цикл, поэтому разница между вызовом функции и отсутствием вызова функции составляет около 5 тактов на итерацию [включая суммирование результатов и другую необходимую обработку], а виртуальный вызов добавляет 22 такта на каждую итерацию. итерация, поэтому около 1,5 часов на вызов.

Сомневаюсь, что вы это заметите, если вы сделаете что-то более значимое, чем возврат x + 1 в своей функции.

person Mats Petersson    schedule 14.01.2013
comment
+1 Хороший анализ, хотя вы должны включить поиск и в свою НАСТОЯЩУЮ разницу. Если бы две строки, которые вы показали, на самом деле были всей разницей, виртуальная реализация не была бы медленнее! - person us2012; 15.01.2013
comment
+1, также обратите внимание, что в реальном коде функция, вероятно, выполняет более одного добавления, и разница в вызове будет намного ниже стоимости функции. - person David Rodríguez - dribeas; 15.01.2013
comment
Разница в двух строках заключается в том, что в одной используется косвенный метод адресации *(%rax), а в другой — прямой адрес 400850. Если он вызывается реже, чем в узком цикле, то потребуется дополнительная работа по поиску виртуальной таблицы и т. д., но тогда у вас также будет больше другого кода, чтобы беспокоиться о его влиянии на вызовы. - person Mats Petersson; 15.01.2013
comment
Что я действительно должен добавить, так это if (a) use_cast_to_get_rid_of_volatile(virtual_a) else use_cast_to_get_rid_of_volatile(virtual_b); и сравните это с чередующимися вызовами virtual_a и virtual_b. - person Mats Petersson; 15.01.2013

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

person paulsm4    schedule 14.01.2013
comment
Не правда. Если компилятору известен статический тип переменной во время выполнения, он может вызвать функцию напрямую, а не через виртуальную таблицу. Виртуальные функции используются только с указателями и ссылками. - person Mark Ransom; 15.01.2013
comment
@MarkRansom Все вызовы виртуальных функций проходят через vtbl, независимо от того, знает ли компилятор тип. Глобальный оптимизатор мог бы оптимизировать этот сценарий, но вряд ли он был бы НАСТОЛЬКО агрессивным, если бы не выполнял оптимизацию на основе профиля. Ключом к повышению производительности за счет обхода вызовов vtbl является встраивание. Если компилятор не уверен, что большинство вызовов относятся к определенному типу, он, вероятно, просто сделает косвенный вызов. - person doug65536; 15.01.2013
comment
@ doug65536 doug65536, посмотрите на сгенерированный ассемблер для вызова виртуальной функции для локальной переменной (не указателя или ссылки) и скажите мне, что вы видите. Я согласен, что отказ от vtable, вероятно, не принесет многого. - person Mark Ransom; 15.01.2013
comment
@MarkRansom Обычно, если у вас есть виртуальная функция, вы везде используете указатель базового типа, и он должен быть виртуальным (базовый тип имеет чисто виртуальное объявление для этого метода). Вы правы, компилятор может знать точный тип, как локальная переменная реального типа. - person doug65536; 15.01.2013
comment
И да, мое первоначальное утверждение о том, что все вызовы являются виртуальными, было неверным. Я перепутал то, что я обычно делаю, с тем, что вы можете сделать. - person doug65536; 15.01.2013
comment
Я должен был сказать, что если вы делаете вызов метода с помощью указателя, если этот класс любой базы имеет виртуальный метод с той же сигнатурой, то это будет виртуальный вызов. Если это вызов экземпляра (а не указатель), то он может напрямую вызывать метод этого типа. - person doug65536; 15.01.2013

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

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

person Ulrich Eckhardt    schedule 15.01.2013