Являются ли шаблоны + функторы /lambdas субоптимальными с точки зрения использования памяти?

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

(A) Шаблон функции + функторы

template <class Compare> void compare_int (int a, int b, const std::string& msg, Compare cmp_func) 
{
    if (cmp_func(a, b)) std::cout << "a is " << msg << " b" << std::endl;
    else std::cout << "a is not " << msg << " b" << std::endl;
}

struct MyFunctor_LT {
    bool operator() (int a, int b) {
        return a<b;
    }
};

И это будет пара вызовов этой функции:

MyFunctor_LT mflt;
MyFunctor_GT mfgt; //not necessary to show the implementation
compare_int (3, 5, "less than", mflt);
compare_int (3, 5, "greater than", mflt);

(B) Шаблон функции + lambdas

Мы будем называть compare_int следующим образом:

compare_int (3, 5, "less than", [](int a, int b) {return a<b;});
compare_int (3, 5, "greater than", [](int a, int b) {return a>b;});

(C) Шаблон функции + std:: function

Та же реализация шаблона, вызов:

std::function<bool(int,int)> func_lt = [](int a, int b) {return a<b;}; //or a functor/function
std::function<bool(int,int)> func_gt = [](int a, int b) {return a>b;}; 

compare_int (3, 5, "less than", func_lt);
compare_int (3, 5, "greater than", func_gt);

(D) Указатели "C-style"

Реализация:

void compare_int (int a, int b, const std::string& msg, bool (*cmp_func) (int a, int b)) 
{
 ...
}

bool lt_func (int a, int b) 
{
    return a<b;
}

Призвание:

compare_int (10, 5, "less than", lt_func); 
compare_int (10, 5, "greater than", gt_func);

В этих сценариях мы имеем в каждом случае:

(A) Два экземпляра шаблона (два разных параметра) будут скомпилированы и выделены в памяти.

(B) Я бы сказал, что два экземпляра шаблона будут скомпилированы. Каждая лямбда - это другой класс. Поправьте меня, если я ошибаюсь, пожалуйста.

(C) Скомпилирован только один экземпляр шаблона, поскольку параметр шаблона всегда те же: std::function<bool(int,int)>.

(D) Очевидно, что у нас есть только один экземпляр.

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

Можно ли сказать, что во многих случаях (т.е. при использовании слишком большого числа функторов с одной и той же сигнатурой) std::function (или даже указатели на функции) должны быть предпочтительнее над шаблонами + исходными функторами /lambdas? Обертка вашего функтора или лямбда с помощью std::function может быть очень удобной.

Я знаю, что std::function (указатель функции тоже) вводит служебные данные. Стоит ли это?

EDIT. Я сделал очень простой тест, используя следующие макросы и очень распространенный шаблон стандартной библиотеки (std:: sort):

#define TEST(X) std::function<bool(int,int)>  f##X = [] (int a, int b) {return (a^X)<(b+X);}; \
std::sort (v.begin(), v.end(), f##X);

#define TEST2(X) auto f##X = [] (int a, int b) {return (a^X)<(b^X);}; \
std::sort (v.begin(), v.end(), f##X);

#define TEST3(X) bool(*f##X)(int, int) = [] (int a, int b) {return (a^X)<(b^X);}; \ 
std::sort (v.begin(), v.end(), f##X);

Ниже приведены результаты относительно размера сгенерированных двоичных файлов (GCC при -O3):

  • Binary с 1 экземпляром макроса TEST: 17009
  • 1 экземпляр макроса TEST2: 9932
  • 1 экземпляр макроса TEST3: 9820
  • 50 экземпляров макросов TEST: 59918
  • 50 экземпляров макроса TEST2: 94682
  • 50 экземпляров макроса TEST3: 16857

Даже если я показал цифры, это более качественный, чем количественный ориентир. Как мы и ожидали, функциональные шаблоны, основанные на std::function параметре или указателе функции, выглядят лучше (с точки зрения размера), так как создается не так много экземпляров. Однако я не измерял использование памяти во время выполнения.

Что касается результатов работы (размер вектора составляет 1000000 элементов):

  • 50 экземпляров макросов TEST: 5.75s
  • 50 экземпляров макроса TEST2: 1,54 с
  • 50 экземпляров макроса TEST3: 3.20s

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

Ответ 1

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

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

Что касается времени компиляции, я бы не стал слишком беспокоиться об этом, если вы используете простые шаблоны, как показано на рисунке. (Если вы делаете метапрограммирование шаблона, да, тогда вы можете начать беспокоиться.)

Теперь использование памяти: компилятором во время компиляции или сгенерированным исполняемым файлом во время выполнения? Для первого - то же самое, что и для времени компиляции. Для последнего: победителями являются Inlined lamdas и функциональные объекты.

Можно ли сказать, что во многих случаях std::function (или даже функциональные указатели) должны быть предпочтительнее шаблонов + raw функторы/лямбды? То есть обертывание вашего функтора или лямбда с помощью std::function может быть очень удобным.

Я не совсем уверен, как ответить на этот вопрос. Я не могу определить "многие обстоятельства".

Однако одно можно сказать наверняка, что стирание стилей - это способ избежать/уменьшить раздувание кода из-за шаблонов, см. пункт 44: независимый от фактора параметр кода из шаблонов в Эффективный С++. Кстати, std::function использует стирание типа внутри. Так что да, раздувание кода - проблема.

Я знаю, что std:: function (указатель функции тоже) вводит служебные данные. Стоит ли это?

"Хотите скорость? Мера". (Ховард Хиннант)

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

#include <cstdio>

bool lt_func(int a, int b) 
{
    return a<b;
}

void compare_int(int a, int b, const char* msg, bool (*cmp_func) (int a, int b)) {
    if (cmp_func(a, b)) printf("a is %s b\n", msg);
    else printf("a is not %s b\n", msg);
}

void f() {
  compare_int (10, 5, "less than", lt_func); 
}

Это немного измененная версия вашего кода. Я удалил все файлы iostream, потому что это заставляет сгенерированную сборку загромождать. Вот сборка f():

.LC1:
    .string "a is not %s b\n"
[...]
.LC2:
    .string "less than"
[...]
f():
.LFB33:
    .cfi_startproc
    movl    $.LC2, %edx
    movl    $.LC1, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    jmp __printf_chk
    .cfi_endproc

Это означает, что gcc 4.7.2 inlined lt_func в -O3. Фактически, генерируемый код сборки является оптимальным.

Я также проверил: я перенес реализацию lt_func в отдельный исходный файл и оптимизировал время ссылки (-flto). GCC все еще счастливо вложил вызов через указатель функции! Это нетривиально, и для этого вам нужен качественный компилятор.


Только для записи и что вы действительно можете почувствовать накладные расходы при использовании std::function:

Этот код:

#include <cstdio>
#include <functional>

template <class Compare> void compare_int(int a, int b, const char* msg, Compare cmp_func) 
{
    if (cmp_func(a, b)) printf("a is %s b\n", msg);
    else printf("a is not %s b\n", msg);
}

void f() {
  std::function<bool(int,int)> func_lt = [](int a, int b) {return a<b;};
  compare_int (10, 5, "less than", func_lt); 
}

дает эту сборку в -O3 (около 140 строк):

f():
.LFB498:
    .cfi_startproc
    .cfi_personality 0x3,__gxx_personality_v0
    .cfi_lsda 0x3,.LLSDA498
    pushq   %rbx
    .cfi_def_cfa_offset 16
    .cfi_offset 3, -16
    movl    $1, %edi
    subq    $80, %rsp
    .cfi_def_cfa_offset 96
    movq    %fs:40, %rax
    movq    %rax, 72(%rsp)
    xorl    %eax, %eax
    movq    std::_Function_handler<bool (int, int), f()::{lambda(int, int)#1}>::_M_invoke(std::_Any_data const&, int, int), 24(%rsp)
    movq    std::_Function_base::_Base_manager<f()::{lambda(int, int)#1}>::_M_manager(std::_Any_data&, std::_Function_base::_Base_manager<f()::{lambda(int, int)#1}> const&, std::_Manager_operation), 16(%rsp)
.LEHB0:
    call    operator new(unsigned long)
.LEHE0:
    movq    %rax, (%rsp)
    movq    16(%rsp), %rax
    movq    $0, 48(%rsp)
    testq   %rax, %rax
    je  .L14
    movq    24(%rsp), %rdx
    movq    %rax, 48(%rsp)
    movq    %rsp, %rsi
    leaq    32(%rsp), %rdi
    movq    %rdx, 56(%rsp)
    movl    $2, %edx
.LEHB1:
    call    *%rax
.LEHE1:
    cmpq    $0, 48(%rsp)
    je  .L14
    movl    $5, %edx
    movl    $10, %esi
    leaq    32(%rsp), %rdi
.LEHB2:
    call    *56(%rsp)
    testb   %al, %al
    movl    $.LC0, %edx
    jne .L49
    movl    $.LC2, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    call    __printf_chk
.LEHE2:
.L24:
    movq    48(%rsp), %rax
    testq   %rax, %rax
    je  .L23
    leaq    32(%rsp), %rsi
    movl    $3, %edx
    movq    %rsi, %rdi
.LEHB3:
    call    *%rax
.LEHE3:
.L23:
    movq    16(%rsp), %rax
    testq   %rax, %rax
    je  .L12
    movl    $3, %edx
    movq    %rsp, %rsi
    movq    %rsp, %rdi
.LEHB4:
    call    *%rax
.LEHE4:
.L12:
    movq    72(%rsp), %rax
    xorq    %fs:40, %rax
    jne .L50
    addq    $80, %rsp
    .cfi_remember_state
    .cfi_def_cfa_offset 16
    popq    %rbx
    .cfi_def_cfa_offset 8
    ret
    .p2align 4,,10
    .p2align 3
.L49:
    .cfi_restore_state
    movl    $.LC1, %esi
    movl    $1, %edi
    xorl    %eax, %eax
.LEHB5:
    call    __printf_chk
    jmp .L24
.L14:
    call    std::__throw_bad_function_call()
.LEHE5:
.L32:
    movq    48(%rsp), %rcx
    movq    %rax, %rbx
    testq   %rcx, %rcx
    je  .L20
    leaq    32(%rsp), %rsi
    movl    $3, %edx
    movq    %rsi, %rdi
    call    *%rcx
.L20:
    movq    16(%rsp), %rax
    testq   %rax, %rax
    je  .L29
    movl    $3, %edx
    movq    %rsp, %rsi
    movq    %rsp, %rdi
    call    *%rax
.L29:
    movq    %rbx, %rdi
.LEHB6:
    call    _Unwind_Resume
.LEHE6:
.L50:
    call    __stack_chk_fail
.L34:
    movq    48(%rsp), %rcx
    movq    %rax, %rbx
    testq   %rcx, %rcx
    je  .L20
    leaq    32(%rsp), %rsi
    movl    $3, %edx
    movq    %rsi, %rdi
    call    *%rcx
    jmp .L20
.L31:
    movq    %rax, %rbx
    jmp .L20
.L33:
    movq    16(%rsp), %rcx
    movq    %rax, %rbx
    testq   %rcx, %rcx
    je  .L29
    movl    $3, %edx
    movq    %rsp, %rsi
    movq    %rsp, %rdi
    call    *%rcx
    jmp .L29
    .cfi_endproc

Какой подход вы хотели бы выбрать, когда дело доходит до производительности?

Ответ 2

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

Если вы держите свою лямбду, например. auto a = []{};, то это будет так же быстро, как и встроенная функция (возможно, даже быстрее, потому что нет никакого преобразования в указатель функции при передаче в качестве аргумента функции).

Объектный код, сгенерированный объектами lambda и inlineable, будет равен нулю при компиляции с включенными оптимизациями (-O1 или выше в моих тестах). Иногда компилятор может отказаться от встроенного, но это обычно происходит только при попытке встроить большие тела функций.

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

Ответ 3

Я расскажу о том, что происходит наивно, и об общих оптимизациях.

(A) Шаблон функции + функторы

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

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

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

Если у вас было несколько объектов функций с одной и той же реализацией, havine один экземпляр функтора, несмотря на это, требует немного усилий, но некоторые компиляторы могут это сделать. Вложение функций template также может быть легким. В примере с вашими игрушками, поскольку входы известны во время компиляции, ветки могут быть оценены во время компиляции, удалены мертвый код и все сводится к одному вызову std::cout.

(B) Шаблон функции + lambdas

В этом случае каждая лямбда является ее собственным типом, и каждый экземпляр закрытия представляет собой экземпляр размера undefined этого лямбда (обычно 1 байт, поскольку он ничего не фиксирует). Если идентичная лямбда определена и используется в разных местах, это разные типы. Каждое место вызова для объекта функции представляет собой отдельное создание функции template.

Удаление существования 1 байтовых затворов, считая их без гражданства, легко. Вложение их также легко. Удаление дублированных экземпляров функции template с той же реализацией, но с другой подписью сложнее, но некоторые компиляторы сделают это. Вложение указанных функций не сложнее, чем указано выше. В примере с вашими игрушками, поскольку входы известны во время компиляции, ветки могут быть оценены во время компиляции, удалены мертвый код и все сводится к одному вызову std::cout.

(C) Шаблон функции + std:: function

std::function - объект стирания типа. Экземпляр std::function с заданной сигнатурой имеет тот же тип, что и другой. Тем не менее, конструктор для std::function равен template d для передаваемого типа. В вашем случае вы проходите в лямбда - поэтому каждое место, где вы инициализируете std::function с помощью лямбда, генерирует отдельный std::function конструктор, который не знает код.

Типичная реализация std::function будет использовать шаблон pImpl для хранения указателя на абстрактный интерфейс на вспомогательный объект, который обертывает ваш вызываемый, и знает, как копировать/перемещать/вызывать его с помощью подписи std::function, Один такой вызываемый тип создается для каждого типа std::function, построенного из одной подписи std::function, для которой он построен.

Будет создан один экземпляр функции, взяв std::function.

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

Хотя функция template может быть inline d, я не знаю компилятора С++, который может оптимизировать существование std::function, поскольку они обычно реализуются как библиотечные решения, состоящие из относительно сложных и непрозрачных код компилятору. Поэтому, хотя теоретически это можно было бы оценить по мере того, как вся информация есть, на практике std::function не будет встроена в функцию template, и не будет устранено уничтожение мертвого кода. Обе ветки превратятся в результирующий двоичный файл вместе с кучей шаблона std::function для себя и его помощника.

Вызов std::function примерно такой же дорогой, как вызов метода virtual - или, как правило, очень грубо, так же как и два вызова указателя функций.

(D) Указатели "C-style"

Создается функция, ее адрес принимается, этот адрес передается на compare_int. Затем он разыскивает указатель на поиск фактической функции и называет ее.

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

Обратите внимание, что вы можете вызывать необработанные указатели "C-style" с безстоящими lambdas, поскольку lambdas без сохранения состояния неявно конвертирует в указатели на функции. Также обратите внимание, что этот пример строго слабее других: он не принимает функции с сохранением состояния. Версия с такой же мощностью будет функцией C-стиля, которая принимает как пару состояний int, так и void*.

Ответ 4

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

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

Ответ 5

В A, B и C вы, вероятно, получаете двоичный файл, который не содержит компаратора, и никаких шаблонов. Это будет буквально включать все сравнения и, возможно, даже удалить недействительную ветвь печати - по сути, она будет "помещать" вызовы для фактического результата, без каких-либо проверок, которые нужно сделать.

В D ваш компилятор не может этого сделать.

Скорее, он более оптимален в этом примере. Он также более гибкий - функция std:: может скрывать элементы, которые хранятся, или просто быть простой функцией C, или быть сложным объектом. Это даже позволяет вывести реализацию из аспектов типа - если бы вы могли сделать более эффективное сравнение типов POD, вы можете реализовать это и забыть об этом для остальных.

Подумайте об этом так: A и B - это более абстрактные реализации, которые позволяют вам сообщать компилятору. "Этот код, вероятно, лучше всего реализуется для каждого типа отдельно, так что сделайте это для меня, когда я его использую". C - это тот, который говорит: "Будет множество операторов сравнения, которые являются сложными, но все они будут выглядеть так, чтобы сделать только одну реализацию функции compare_int". В D вы говорите "Не беспокойтесь, просто сделайте мне эти функции. Я знаю лучше всего". Нет однозначно лучше остальных.