О задаче
Некоторое время назад появилась необходимость реализовать небольшой модуль, который в зависимости от пользовательской (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)
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...). Но, имхо, здесь маловато новаторства, потому статьи оно недостойно.kgill-leebr
18.09.2016 23:55+1В указанных статьях требовался несколько другой результат. Собственно таки есть std::function и boost, которые дают общее решение. Если же его (общее) начинать приспосабливать под конкретный случай — иногда получается спагетти. Про новаторство — имхо, если прочитать Страуструпа и полную документацию по С++ (включая STL), то новаторства нет вообще. Но за сами ссылки спасибо — полезно знать любое мнение.
Door
18.09.2016 23:59+1Просто хочу заметить, что "если не сильно менять и причёсывать" код из статьи, то, как раз таки,
std::forward()
там нигде не нужно, просто потому что форвардинг ссылки нигде и не используются (а должны! К сожалению, иstd::move()
упущен… ладно).
По поводу вашей реализации, извините, я не сильно всматривался, вам не хватило аллокатора который есть в интерфейсе std::function?
Плюс, по поводуstd::forward()
, в этом месте — кхм, навскидку не понял, почему тамstd::forward()
, а неstd::move()
? Судя по этому месту, я дико извиняюсь, — вы неправльно используетеstd::forward()
?SBKarr
19.09.2016 00:48Ну, если вспоминать свой код… опасность в том, что для сей структуры может быть не вызван деструктор, и std::function не гарантирует работу без утечек памяти в таких условиях. Конкретные случаи объяснить без доступа к коду, который это дело использует сходу не смогу (разве что сказать, что в первом случае, который на самом деле второй, действительно нужен std::move, но в силу стандартной реализации оно работает как предполагается), ибо времени прошло прилично. Могу лишь сказать, что на данный момент оно работает в небольшом, но репрезентативном объёме.
SBKarr
19.09.2016 01:28Про второй случай с std::forward, тут я, возможно, не понимаю сути раскрытия parameter pack, но компилятор не разрешает в такой реализации принимать его по универсальной ссылке, а если не прокидывать аргументы, которые в самой упаковке идут как rvalue (сигнатура вида <void(string &&)>), то они совершенно справедливо станут lvalue, чем перестанут соответствовать сигнатуре вызываемой впоследствии функции. Как это точно описывает стандарт, я понять не могу, поэтому решение найдено эмпирически и проверено функционально.
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()
. Ниже, по коду, он нигде не используется, поэтому я спокойно его муваю, тем самым говоря, что я его больше не использую и "делайте со мной что хотите".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, что, вапще говоря, серьёзное упущение. Хорошо, что я туда не полез из других соображений, а то долго бы ловил получившийся гейзенбаг.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.
Забавно
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 в таком случае не вылазит. Как я уже говорил, не могу объяснить это словами стандарта, только практикой использования.
izvolov
18.09.2016 12:10Во-первых, на таких простых примерах запросто может происходить девиртуализация, так что не факт, что эксперимент корректен.
Во-вторых, статическая переменная инициализируется один раз, поэтому чудо-объект
Wrap
одноразовый.
Не занимайтесь ерундой и либо используйте шаблоны (для гарантированного встраивания), либо, если нет возможности, берите
std::function
. В гцц она гарантированно не делает виртуальных вызовов, а для маленьких функциональных объектов даже не выделяет динамическую память.spot62
14.01.2017 15:02http://adopt-zu.soroka.org.ua/vosst.html
на самом деле есть сайт, где все измышления автора более-менее систематизированыizvolov
19.09.2016 00:36что вроде бы и логично
Нет, не логично. Могут быть объекты одного типа, но с разным состоянием.
Код по ссылке не подвержен девиртуализации. Собственно ее для тестом можно исключить выбирая метод по пользовательскому вводу
Пользовательский ввод сам по себе не гарантирует отключение девиртуализации. Нужно более серьёзное обоснование.
Про std::function опять-же для других целей все замечательно. Для конкретно решавшейся задачи — это сильное усложнение и кода и механизма.
Хотелось бы увидеть ассемблерный код ваших обёрток в сравнении с ассемблерным кодом
std::function
на более-менее реальной программе.
Пока что весь ассемблерный код был показан только на вырванных из контекста участках кода. На которых компилятор, естественно, всё хорошо соптимизировал.izvolov
19.09.2016 00:41+1А лучше всего не ограничиваться ассемблером, а ещё и замерить реальное время работы.
kgill-leebr
19.09.2016 00:54Когда оно попадет в проект — постараюсь добавить сюда результаты юнит-тестов. Но изначально это не совсем замена для std::function. Здесь основная цель перекинуть все на виртуальность.
Ситуацию с множественным вызовом Wrap я действительно не рассмотрел. Как только смогу — докину апдейт.
А вот про девиртуализацию можно поспорить. Зависимость от пользовательского ввода или других рантайм данных (скажем чтение из файла или банально результат random) не позволяет компилятору определить что именно он должен девиртуализировать. По крайней мере я не знаком и не находил подобных возможностей. Если можете — пожалуйста опишите конкретнее или дайте пример.
nckma
18.09.2016 20:23А что правда, что глядя на ассемблерный код можно сказать, что это будет быстрее, а вот это помедленней?
Может я не прав, но мне кажется, что у современных процессоров Интел время исполнения очереди команд такое не очевидное…
Раньше да, как-то можно было прикинуть в уме, хотя бы примерно одна команда один такт, но это правило уже давно не работает.MichaelBorisov
18.09.2016 21:04+2Грубо прикинуть иногда можно. Так, можно ожидать, что быстрее будет работать более короткий код, содержащий меньше команд. Команда команде рознь, конечно, но если команды все простые (mov, add, sub и т.п.) — то можно ожидать, что они примерно равноценны.
Далее, если есть условные или косвенные переходы — то можно ожидать, что такой код будет работать медленнее по сравнению со случаем, когда условных и косвенных переходов нет.
Также могут сильно тормозить обращения к памяти. Когда их меньше — то скорее всего будет работать быстрее.
Ну и «тяжелые» команды — вычисления с плавающей запятой, всякие там синусы, деление — все это тоже долго исполняется по сравнению с mov/add.nckma
19.09.2016 10:15У меня просто есть печальный опыт: мы пытались длинную си-шную функцию переписать на ассемблере, чтоб было быстрее. Ко всеобщему изумлению — не смогли. Вроде бы и инструкций меньше, и короче выглядит, а по измерениям получается не быстрее.
Обращения к памяти кажется не тормозят, видимо все в кеш попадало.
Переходы так же быстры и на них потерь не много — видимо хорошо работает предсказание переходов.MichaelBorisov
19.09.2016 21:38Ну, бывает и такое. Быть может, ваш алгоритм действительно не подлежал оптимизации. Во всяком случае, вы испробовали все способы и теперь более-менее точно знаете это.
Я думаю, что ваш результат — это не повод отказываться от ассемблера во всех случаях, так же как и мои положительные результаты — не повод всегда ломиться в ассемблер. Тут нужен опыт и интуиция для принятия правильных решений. И добывается такой опыт практикой.
kgill-leebr
19.09.2016 00:071) Можно оценить какие типы инструкций присутствуют, может ли код быть дальше оптимизирован компилятором если применять в реальном проекте.
2) При желании можно открыть справочник по процессорам (собственно что и делалось), и оценить расходы на выполнение каждой инструкции (хотя-бы приблизительно для целевого железа).
aamonster
19.09.2016 00:07+1Что-то меня нервирует момент с функцией Wrap в разделе «функторы»:
внутри неё определёна статическая переменная W — значит, для всех вызовов с одинаковым аргументом темплейта (Func) будет работать с одной и той же переменной W (проинициализирована она будет только при первом вызове для данного Func).
Т.е. вызов Wrap для нескольких функторов одного типа вернёт один и тот же результат.
Я где-то ошибся? (проверять сейчас некогда)kgill-leebr
19.09.2016 00:12Мне действительно стоит проверить и описать этот аспект работы кода. Спасибо за замечание.
Ariox41
19.09.2016 00:12+1Подобный механизм имеет смысл только в случае необходимости нескольких виртуальных методов. std::function как обертка для лямбд на gcc 5.4 часто работает быстрее, чем виртуальные функции.
kgill-leebr
19.09.2016 00:15Да, всё верно. Суть именно в виртуализации. Изначально требовалось по указателям на одинаковый тип добираться до различных методов и данных. Это это-же по сути и делает механизм виртуальности. Остальное — обертка чтобы сгрузить всё на него.
victor1234
19.09.2016 15:10Вы можете кратко пояснить особенности использования default в этом контексте, какие могут всплыть моменты при реализации родственных задач?
vagran
Не могу понять — тут std::function переизобретается?
thedsi666
В отличие от std::function здесь не используется динамическое выделение памяти. Вместо этого функторы хранятся как статические объекты, что, очевидно, имеет свои минусы, но лучше в плане производительности.
kgill-leebr
Если ооочень грубо — то похоже. Разница с ходу в том, что результирующий тип — пользовательский класс.
+ для перфекционистов код несколько проще.