В этой статье показаны некоторые механизмы, позволяющие получить достаточно производительный (встраиваемый во время компиляции) и легко масштабируемый код для управления вызовами различных объектов при помощи стандартных технологий С++.

О задаче


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

Для большей простоты и читабельности в примерах кода будут показаны только наиболее сложные ключевые механизмы. Примеры машинного кода приводятся для компилятора майкрософт при оптимизации О2.

Первые шаги


Решением задачи в стиле «Си» было бы простое использование указателей на функции, значения которых задаются при обработке данных. Однако помимо самих указателей требовалось хранить некую дополнительную информацию для каждой функции. В результате для обеспечения наиболее общего решения выбор остановился на абстрактном классе с необходимым набором полей и методов.

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

Простой пример такого класса:

struct MyObj
{
    using FType = int( *)(int, int);

    virtual int operator() ( int a, int b ) = 0;
    virtual ~MyObj() = default;
};

Здесь основным является виртуальный оператор "()", Виртуальный деструктор нужен из очевидных соображений, а FType всего-лишь определяет семантику основного метода в плане типов аргументов и возврата.

Имея подобный класс манипуляции с указателями на функции заменяются работой с указателями на тип MyObj. Указатели можно удобно хранить в списках или, скажем, таблицах, и всё, что остается — это правильно инициализировать. Основное же отличие заключается в том, что объекты могут иметь состояние и для них применим механизм наследования. Это значительно расширяет и упрощает возможности добавления в данный код различного готового функционала из внешних библиотек.

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

Встраивание


Собственно важнейшим шагом к оптимальной работе программы является написание встраиваемого (inline) кода. По сути для этого нужно, чтобы выполняемая последовательность инструкций минимально зависела от runtime данных. В таком случае компилятор сможет встраивать код функций на место их вызова вместо перехода (вызова) по адресу и/или выкидывать ненужные куски кода. Эти же критерии позволяют собрать машинный код избегая лонг джампов и частого изменения процессорного кеша, но это уже совсем другая история.

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

Наследование


Первым делом стоит разобраться непосредственно с наследованием абстрактного класса. Простейший способ это «ручная» перегрузка оператора при наследовании. К примеру:

struct : public MyObj {
    int operator()( int a, int b ) override { return a + b; };
}addObj;    // manually inherited structure

MyObj* po = &addObj;

int res = (*po)( a, b );

В этом случае получается так, что оптимизированный вызов виртуального метода перенесет сразу на складывание двух чисел. MSVS при оптимизации О2 выдает примерно такой машинный код для вызова* (подготовка регистров, укладка аргументов):

push        dword ptr [b]  
mov         eax,dword ptr [esi]  
mov         ecx,esi  
push        dword ptr [a]  
call        dword ptr [eax]  

и такой код для собственно перегруженного метода:

push        ebp  
mov         ebp,esp  
mov         eax,dword ptr [a]  
add         eax,dword ptr [b]  
pop         ebp  
ret         8  

*Первая часть абсолютно одинакова для всех случаев, по скольку зависит только от семантики самого вызова, потому этот код дальше будет упускаться. В этой статье всегда используется вариант res = (*po)(a, b);.

В некоторых случаях оптимизация бывает еще лучше, например g++ может сжать складывание целых чисел до 2 инструкций: lea, ret. В данной статье для краткости я ограничусь примерами, полученными на майкрософтовском компиляторе, при этом замечу, что код также проверялся на g++ под linux.

Функторы


Логичным продолжением является вопрос «а что если надо выполнять сложный код, реализованный в сторонних функциях?». Естественно этот код надо выполнять внутри перегруженного метода у наследника MyObj, но если вручную создавать для каждого случая свой (пусть даже анонимный) класс, инициализировать его объект и передавать его адрес, то про понятность и масштабируемость можно даже не вспоминать.

К счастью в С++ для этого есть великолепный механизм шаблонов, который подразумевает именно compile-time разрешение кода и, соответственно, встраивание. Таким образом можно оформить простой шаблон, который будет принимать параметром какой-либо функтор, создавать анонимный класс-наследник MyObj и внутри перегруженного метода вызывать полученный параметр.

Но (конечно есть «но»), как же лямбды и другие динамические объекты? Стоит заметить что лямбды в C++ ввиду их реализации и поведения надо воспринимать именно как объекты, а не как функции. К большому сожалению ламбда-выражения в C++ не удовлетворяют требованиям параметра шаблона. Эту проблему рвутся исправить в 17-ом стандарте, а даже и без него не всё так плохо.

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

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

template<class Func>
class Wrapping : public MyObj
{
    Func _f;
public:

    Wrapping( Func f ) : _f( f ) {};
    int operator()( int a, int b ) override { return _f( a, b ); }
};

template<class Func>
Wrapping<Func>* Wrap( Func f )
{
    static Wrapping<Func> W( f );
    return &W;
}

Для инициализации указателя надо просто вызвать функцию Wrap и передать аргументом нужный объект. При чем ввиду особенностей концепции функтора (а это именно с ним работа) аргументом может быть абсолютно любой выполняемый объект или просто функция с соответствующим количеством аргументов, даже если они другого типа.

Примером вызова может быть:

po = Wrap( []( int a, int b ) {return a + b; } );

Несмотря на сложный вид — набор инструкций у перегруженного оператора «()» будет очень простой, собственно идентичный полученному при ручном наследовании и встраивании:

push        ebp  
mov         ebp,esp  
mov         eax,dword ptr [a]  
add         eax,dword ptr [b]  
pop         ebp  
ret         8  

Все сложные условные переходы и инициализации происходят при вызове Wrap, после чего остается только механизм вызова виртуальных методов. Кроме всего работа идет со статическим объектом, а значит есть надежда на отсутствие обращений к куче и лонгджампов.

Интересно, что могут быть встроены практические любые экземпляры. Например код:

struct AddStruct {
    int operator()( int a, int b ) { return a + b; }
};
...
op = Wrap( AddStruct() );

Будет иметь следующий машинный код перегруженного оператора:

push        ebp  
mov         ebp,esp  
mov         eax,dword ptr [a]  
add         eax,dword ptr [b]  
pop         ebp  
ret         8

Т.е. такой-же как и при ручном встраивании. Мне удавалось получить подобный машинный код даже для объекта, созданного через new. Но этот пример оставим в стороне.

Функции


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

int sub( int a, int b ) { return a + b; };
...
po = Wrap( sub );

Но в машинном коде перегруженного метода будет находиться еще один вызов соответственно с переходом:

push        ebp  
mov         ebp,esp  
push        dword ptr [b]  
mov         eax,dword ptr [ecx+4]  
push        dword ptr [a]  
call        eax  
add         esp,8  
pop         ebp  
ret         8  

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

Функции с идентичной семантикой


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

template<class Func, Func f>
struct FWrapping : public MyObj
{
    int operator ()( int a, int b ) override { return f( a, b ); }
};

template<MyObj::FType f>
FWrapping<MyObj::FType, f>* Wrap()
{
    static FWrapping<MyObj::FType, f> W;
    return &W;
}

Оборачивая перегруженную Wrap для функций вида:


int add( int a, int b ) { return a + b; }
...
po = Wrap<add>();

Можно получить оптимальный машинный код, идентичный полученному при ручном наследовании:

push        ebp  
mov         ebp,esp  
mov         eax,dword ptr [a]  
add         eax,dword ptr [b]  
pop         ebp  
ret         8  

Функции с отличной семантикой


Последним вопросом остается ситуация, когда необходимая для встраивания функция не совпадает по типам с объявленной в MyObj. Для этого случая можно легко добавить еще одну перегрузку функции-заворачивателя, в которой тип будет передаваться как еще один параметр шаблона:

template<class Func, Func f>
FWrapping<Func, f>* Wrap()
{
    static FWrapping<Func, f> W;
    return &W;
}

Вызов данной функции требует ручного указания типа передаваемой функции, что не всегда удобно. Для упрощения кода можно использовать ключевое слово decltype( ):

po = Wrap<decltype( add )*, add>();

Важно заметить необходимость ставить «*» после decltype, иначе среда разработки может выдавать сообщение об ошибке про отсутствие реализации Wrap, удовлетворяющей данным аргументам. Несмотря на это скорее всего проект нормально скомпилируется. Данное несоответствие вызвано правилами определения типов при передаче в шаблон и, собственно, принципом работы decltype. Чтобы избежать сообщения об ошибке можно воспользоваться такой конструкцией, как std::decay для гарантированно корректной подстановки типа, которую удобно завернуть в простой макрос:

#define declarate( X ) std::decay< decltype( X ) >::type
...
po = Wrap<declarate( add ), add>();

Либо же просто отслеживать соответствие вручную, если вы не хотите плодить сущности.

Разумеется машинный код при встраивании подобной функции будет отличаться, поскольку требуется как минимум преобразование типов. К примеру при вызове функции, заданной как:

float fadd( float a, float b ) { return a + b; }
...
op = Wrap<declarate(fadd), fadd>();

Из дезассемблера выйдет примерно это:

push        ebp  
mov         ebp,esp  
movd        xmm1,dword ptr [a]  
movd        xmm0,dword ptr [b]  
cvtdq2ps    xmm1,xmm1  
cvtdq2ps    xmm0,xmm0  
addss       xmm1,xmm0  
cvttss2si   eax,xmm1  
pop         ebp  
ret         8

Функции вместе


Получив дополнительные перегрузки функции Wrap для встраивания собственно других функций можно во избежание повторения кода и для приближения к Дзен переопределить один из вариантов через вызов другого:

template<class Func, Func f>
FWrapping<Func, f>* Wrap()
{
    static FWrapping<Func, f> W;
    return &W;
}
template<MyObj::FType f>
FWrapping<MyObj::FType, f>* Wrap()
{
    return Wrap<MyObj::FType, f>();
}

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

Всё вместе


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

*достаточно близкий для данного примера означает совпадение по количеству аргументов и при условии совпадения либо возможности неявного преобразования типов.

struct MyObj
{
    using FType = int( *)(int, int);

    virtual int operator() ( int a, int b ) = 0;
    virtual ~MyObj() = default;
};

template<class Func>
class Wrapping : public MyObj
{
    Func _f;
public:

    Wrapping( Func f ) : _f( f ) {};
    int operator()( int a, int b ) override { return _f( a, b ); }
};
template<class Func, Func f>
struct FWrapping : public MyObj
{
    int operator ()( int a, int b ) override { return f( a, b ); }
};

template<class Func>
Wrapping<Func>* Wrap( Func f )
{
    static Wrapping<Func> W( f );
    return &W;
}
template<class Func, Func f>
FWrapping<Func, f>* Wrap()
{
    static FWrapping<Func, f> W;
    return &W;
}
template<MyObj::FType f>
FWrapping<MyObj::FType, f>* Wrap()
{
    return Wrap<MyObj::FType, f>();
}

#define declarate( X ) std::decay< decltype( X ) >::type

Потенциальной проблемой для данного механизма является необходимость «заворачивать» функции с отличным количеством аргументов или не приводимыми (неявно) типами. Неким решением является вызов таких функций (функторов) внутри заворачиваемой лямбды. Например:

int volume( const double& a, const double& b, const double& c ) { return a*b*c; };
...
po = Wrap( []( int a, int b )->int { return volume( a, b, 10 ); } );

Примеры кода находятся здесь. Для сборки нужно использовать С++11. Для того чтобы разглядеть разницу во встраивании — оптимизацию О2. Код подготовлен так, чтобы избежать излишнего встраивания.
Поделиться с друзьями
-->

Комментарии (28)


  1. vagran
    18.09.2016 08:07
    +6

    Не могу понять — тут std::function переизобретается?


    1. thedsi666
      18.09.2016 18:48
      +1

      В отличие от std::function здесь не используется динамическое выделение памяти. Вместо этого функторы хранятся как статические объекты, что, очевидно, имеет свои минусы, но лучше в плане производительности.


    1. kgill-leebr
      18.09.2016 23:48

      Если ооочень грубо — то похоже. Разница с ходу в том, что результирующий тип — пользовательский класс.
      + для перфекционистов код несколько проще.


  1. SBKarr
    18.09.2016 10:02

    Было на хабре уже как минимум два раза:

    https://habrahabr.ru/post/159389/
    https://habrahabr.ru/post/166589/

    В принципе, я мог бы и про свою реализацию написать: https://github.com/SBKarr/stappler/blob/v2/common/apr/SPAprFunction.h, фича в использовании пулов памяти по образцу Apache Portable Runtime вместо new/delete, плюс отдельный скоростной тип для заворачивания лямбд (и только их) в колбеки, ибо архитектурно это разные подходы. (пока смотрел код, заметил пропущеyный std::forward...). Но, имхо, здесь маловато новаторства, потому статьи оно недостойно.


    1. kgill-leebr
      18.09.2016 23:55
      +1

      В указанных статьях требовался несколько другой результат. Собственно таки есть std::function и boost, которые дают общее решение. Если же его (общее) начинать приспосабливать под конкретный случай — иногда получается спагетти. Про новаторство — имхо, если прочитать Страуструпа и полную документацию по С++ (включая STL), то новаторства нет вообще. Но за сами ссылки спасибо — полезно знать любое мнение.


    1. Door
      18.09.2016 23:59
      +1

      Просто хочу заметить, что "если не сильно менять и причёсывать" код из статьи, то, как раз таки, std::forward() там нигде не нужно, просто потому что форвардинг ссылки нигде и не используются (а должны! К сожалению, и std::move() упущен… ладно).


      По поводу вашей реализации, извините, я не сильно всматривался, вам не хватило аллокатора который есть в интерфейсе std::function?
      Плюс, по поводу std::forward(), в этом месте — кхм, навскидку не понял, почему там std::forward(), а не std::move()? Судя по этому месту, я дико извиняюсь, — вы неправльно используете std::forward()?


      1. SBKarr
        19.09.2016 00:48

        Ну, если вспоминать свой код… опасность в том, что для сей структуры может быть не вызван деструктор, и std::function не гарантирует работу без утечек памяти в таких условиях. Конкретные случаи объяснить без доступа к коду, который это дело использует сходу не смогу (разве что сказать, что в первом случае, который на самом деле второй, действительно нужен std::move, но в силу стандартной реализации оно работает как предполагается), ибо времени прошло прилично. Могу лишь сказать, что на данный момент оно работает в небольшом, но репрезентативном объёме.


      1. SBKarr
        19.09.2016 01:28

        Про второй случай с std::forward, тут я, возможно, не понимаю сути раскрытия parameter pack, но компилятор не разрешает в такой реализации принимать его по универсальной ссылке, а если не прокидывать аргументы, которые в самой упаковке идут как rvalue (сигнатура вида <void(string &&)>), то они совершенно справедливо станут lvalue, чем перестанут соответствовать сигнатуре вызываемой впоследствии функции. Как это точно описывает стандарт, я понять не могу, поэтому решение найдено эмпирически и проверено функционально.


        1. Door
          19.09.2016 02:50
          +1

          Допустим, у нас есть:


          #include <cstdio>
          #include <utility>
          
          struct UserType
          {
          };
          
          // (1)
          void Concrete(UserType&&)
          {
              std::puts("Concrete(UserType&&)");
          }
          
          // (2)
          void Concrete(UserType&)
          {
              std::puts("Concrete(UserType&)");
          }
          
          template<typename T>
          void DoForward(T&& parameter)
          {
              Concrete(std::forward<T>(parameter));
          }
          
          int main()
          {
              UserType value;
              // 1: аргумент (@value) у нас T& (UserType&, lvalue),
              // а параметр (@parameter) - T&& - происходит наложение
              // & + && -> получаем UserType&.
              // Вызывается (2)я версия Concrete()
              DoForward(value);
          
              // 2: Передаём временный обьект - 
              // аргумент (@value) у нас T&& (UserType&&, грубо говоря, rvalue),
              // а параметр (@parameter), всё тот же, T&& - происходит наложение
              // && + && -> получаем UserType&&.
              // Вызывается (1)я версия Concrete()
              DoForward(UserType{});
          }

          parameter в DoForward(), поскольку это шаблонная функция, попадает в (не знаю как перевести) "deduced context" — и всё что в main-е я написал, это, как раз таки, "вывод типа" параметра, который учитывает "шаблонный" тип параметра и тип аргумента функции. Из-за того, что это шаблон, как видно, мы можем получить либо rvalue либо lvalue. Всё просто: "deduced context" — юзаем forward().


          Как работает forward(): для первого вызова в main-е, как я уже написал, тип parameter вывелся в UserType&, т.е., T — это UserType&. forward() принимает аргументом (всегда!) — именованную переменную — т.е., это всегда lvalue. Получаем, что forward() имеет на вход шаблонный аргумент типа UserType& и тип параметра, так же, UserType& — всё это означает, что переданный на вход аргумент — это lvalue! forward() ничего не делает.
          Аналогично, для второго вызова в main-е, тип parameter вывелся в UserType&&, т.е., T — это UserType&&. Получаем, что forward() имеет на вход шаблонный аргумент типа UserType&& и параметр UserType& — переданный на вход аргумент — это rvalue! forward() делает move().


          Во всех остальных случаях — у нас нет "deduced context-а" и всё, каким мы его видим, таким и есть — т.е. T&& — это rvalue — нужно делать move(). T& — это ссылка — делаем что хотим, а T — хм, переменная, которая, больше не используется, поэтому можно сделать move().


          Т.е., в случае:


          template<typename T>
          struct NonDeducedContext
          {
          
              static void call(T parameter)
              {
                  DoForward(std::move(parameter));
              }
          }

          для параметра функции call() нет вывода типа, потому что он уже выведен для класса в целом (мы его указываем при инстанциировании шаблона: NonDeducedContext<UserType>). Т.е., parameter — это value type — это копия аргумента функции call(). Ниже, по коду, он нигде не используется, поэтому я спокойно его муваю, тем самым говоря, что я его больше не использую и "делайте со мной что хотите".


          1. SBKarr
            19.09.2016 10:52

            А теперь представим, что у нас есть функция типа void moveNamedVector(const string &, vector &&). Нам нужно вызывать её из функции, находящейся в структуре, с шаблонным аргументов в виде parameter pack:

            template <typename… Args>
            struct F {
            void f(Args… args) {

            }
            }

            В таком случае, parameter pack должен состоять из <const string &, vector &&>. Когда мы раскрываем такой пакет, по виду moveNamedVector(args...), каждый элемент попадает в deduced context, и при этом имеет имя (пусть виртуальное и недоступное пользователю), а значит, каждый элемент без использования forward будет передан как lvalue. В итоге компилятор будет искать сигнатуру void moveNamedVector(const string &, const vector &), которая не соответствует желаемой. С использованием move(args)… компилятор попробует получить rvalue из всего переданного, и будет искать void (string &&, vector &&), опять промах. Остаётся использовать std::forward. Кстати, gcc и clang в их реализации function со мной согласны.

            P.S. Оказывается GCC требует признать аллокаторы для std::function устаревшими и не поддерживает их совсем. А в реализации MSVC аллокаторы не проверяются на совместимость при move assignment, что, вапще говоря, серьёзное упущение. Хорошо, что я туда не полез из других соображений, а то долго бы ловил получившийся гейзенбаг.


            1. Door
              19.09.2016 16:45

              Не, долго писать, но вкратце:


              С использованием move(args)… компилятор попробует получить rvalue из всего переданного, и будет искать void (string &&, vector &&), опять промах. Остаётся использовать std::forward.

              Если вы будете использовать std::forward() в этом контексте, то это аналогично std::move() для всех параметров. Т.е., с вашим примером, будет std::move() как для 1го аргумента, так и для второго, т.е., если была бы ещё перегрузка moveNamedVector(string&&, vector&&), то вызвалась бы именно она, независимо от того использовали ли бы вы std::move() (более идиоматично) либо std::forward() (нестандартное использование).


              По поводу std::function, да, использование с аллокаторами задепрекейтили в C++17: Deprecating Allocator Support in std::function, потому что:


              there are technical issues with storing an allocator in a type-erased context and then recovering that allocator later for any allocations needed during copy assignment.

              Забавно


              1. SBKarr
                19.09.2016 17:23

                Авторы libc++ со мной солидарны, https://gcc.gnu.org/onlinedocs/gcc-6.2.0/libstdc++/api/a01295_source.html (строки 2129 -2137) в таком использовании std::forward, и по опыту наличия в одном классе перегрузок на все лады: (const string &, string &&), (string &&, const string &), (string &&, string &&), подлянка с перемещением константной lvalue в таком случае не вылазит. Как я уже говорил, не могу объяснить это словами стандарта, только практикой использования.


                1. Door
                  19.09.2016 20:38

                  Спасибо.


  1. izvolov
    18.09.2016 12:10

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


    Во-вторых, статическая переменная инициализируется один раз, поэтому чудо-объект Wrap одноразовый.


    Не занимайтесь ерундой и либо используйте шаблоны (для гарантированного встраивания), либо, если нет возможности, берите std::function. В гцц она гарантированно не делает виртуальных вызовов, а для маленьких функциональных объектов даже не выделяет динамическую память.


    1. spot62
      14.01.2017 15:02

      http://adopt-zu.soroka.org.ua/vosst.html

      на самом деле есть сайт, где все измышления автора более-менее систематизированы


      1. izvolov
        19.09.2016 00:36

        что вроде бы и логично

        Нет, не логично. Могут быть объекты одного типа, но с разным состоянием.


        Код по ссылке не подвержен девиртуализации. Собственно ее для тестом можно исключить выбирая метод по пользовательскому вводу

        Пользовательский ввод сам по себе не гарантирует отключение девиртуализации. Нужно более серьёзное обоснование.


        Про std::function опять-же для других целей все замечательно. Для конкретно решавшейся задачи — это сильное усложнение и кода и механизма.

        Хотелось бы увидеть ассемблерный код ваших обёрток в сравнении с ассемблерным кодом std::function на более-менее реальной программе.
        Пока что весь ассемблерный код был показан только на вырванных из контекста участках кода. На которых компилятор, естественно, всё хорошо соптимизировал.


        1. izvolov
          19.09.2016 00:41
          +1

          А лучше всего не ограничиваться ассемблером, а ещё и замерить реальное время работы.


          1. kgill-leebr
            19.09.2016 00:54

            Когда оно попадет в проект — постараюсь добавить сюда результаты юнит-тестов. Но изначально это не совсем замена для std::function. Здесь основная цель перекинуть все на виртуальность.

            Ситуацию с множественным вызовом Wrap я действительно не рассмотрел. Как только смогу — докину апдейт.

            А вот про девиртуализацию можно поспорить. Зависимость от пользовательского ввода или других рантайм данных (скажем чтение из файла или банально результат random) не позволяет компилятору определить что именно он должен девиртуализировать. По крайней мере я не знаком и не находил подобных возможностей. Если можете — пожалуйста опишите конкретнее или дайте пример.


  1. nckma
    18.09.2016 20:23

    А что правда, что глядя на ассемблерный код можно сказать, что это будет быстрее, а вот это помедленней?
    Может я не прав, но мне кажется, что у современных процессоров Интел время исполнения очереди команд такое не очевидное…
    Раньше да, как-то можно было прикинуть в уме, хотя бы примерно одна команда один такт, но это правило уже давно не работает.


    1. MichaelBorisov
      18.09.2016 21:04
      +2

      Грубо прикинуть иногда можно. Так, можно ожидать, что быстрее будет работать более короткий код, содержащий меньше команд. Команда команде рознь, конечно, но если команды все простые (mov, add, sub и т.п.) — то можно ожидать, что они примерно равноценны.

      Далее, если есть условные или косвенные переходы — то можно ожидать, что такой код будет работать медленнее по сравнению со случаем, когда условных и косвенных переходов нет.

      Также могут сильно тормозить обращения к памяти. Когда их меньше — то скорее всего будет работать быстрее.

      Ну и «тяжелые» команды — вычисления с плавающей запятой, всякие там синусы, деление — все это тоже долго исполняется по сравнению с mov/add.


      1. nckma
        19.09.2016 10:15

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


        1. MichaelBorisov
          19.09.2016 21:38

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

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


    1. kgill-leebr
      19.09.2016 00:07

      1) Можно оценить какие типы инструкций присутствуют, может ли код быть дальше оптимизирован компилятором если применять в реальном проекте.
      2) При желании можно открыть справочник по процессорам (собственно что и делалось), и оценить расходы на выполнение каждой инструкции (хотя-бы приблизительно для целевого железа).


  1. aamonster
    19.09.2016 00:07
    +1

    Что-то меня нервирует момент с функцией Wrap в разделе «функторы»:
    внутри неё определёна статическая переменная W — значит, для всех вызовов с одинаковым аргументом темплейта (Func) будет работать с одной и той же переменной W (проинициализирована она будет только при первом вызове для данного Func).

    Т.е. вызов Wrap для нескольких функторов одного типа вернёт один и тот же результат.

    Я где-то ошибся? (проверять сейчас некогда)


    1. kgill-leebr
      19.09.2016 00:12

      Мне действительно стоит проверить и описать этот аспект работы кода. Спасибо за замечание.


  1. Ariox41
    19.09.2016 00:12
    +1

    Подобный механизм имеет смысл только в случае необходимости нескольких виртуальных методов. std::function как обертка для лямбд на gcc 5.4 часто работает быстрее, чем виртуальные функции.


    1. kgill-leebr
      19.09.2016 00:15

      Да, всё верно. Суть именно в виртуализации. Изначально требовалось по указателям на одинаковый тип добираться до различных методов и данных. Это это-же по сути и делает механизм виртуальности. Остальное — обертка чтобы сгрузить всё на него.


  1. victor1234
    19.09.2016 15:10

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