Самая быстрая реализация простого виртуального шаблона типа наблюдателя на с ++?

Я изо всех сил пытаюсь реализовать альтернативу для vtables, используя перечисления и тонну макро-магии, которая действительно начинает мешать моему мозгу. Я начинаю думать, что иду не по правильному пути, поскольку код становится все уродливее и уродливее, и никоим образом не будет пригоден для производства.

Как можно реализовать шаблон следующего кода с наименьшим количеством перенаправлений / операций?

Это нужно делать на стандартном C ++, до 17.

class A{
    virtual void Update() = 0; // A is so pure *¬*
};

class B: public A
{
    override void Update() final
    {
        // DO B STUFF
    }
}

class C: public A
{
    override void Update() final
    {
        // DO C STUFF
    }
}

// class...

int main()
{
    std::vector<A*> vecA{};

    // Insert instances of B, C, ..., into vecA

    for(auto a: vecA) // This for will be inside a main loop
        a->Update(); // Ridiculous amount of calls per unit of time

    // Free memory
}

PS: Если enum, switch и макросы действительно являются лучшим вариантом, я думаю, я просто попытаюсь освежить свои кеши и придумать лучший дизайн.

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


person NicoBerrogorry    schedule 05.10.2017    source источник
comment
Что точно является узким местом в вашем текущем коде? Получаете ли вы много неверных прогнозов ветвления из-за непрямых вызовов? (вы сделали профиль со счетчиками производительности, верно?) Получаете ли вы промахи кеша при чтении _1_? (т.е. было бы полезно сжать каждый объект, заменив указатель функции vtable на небольшой целочисленный индекс?) Вы настраиваете это для современного большого ядра x86 или для микроконтроллера со слабым косвенным предсказанием ветвлений?   -  person Some programmer dude    schedule 05.10.2017
comment
Вы можете просто использовать vecA и не беспокоиться об управлении памятью или наследовании. Если вы беспокоитесь о производительности, стоит посмотреть объявление _2_ или _3_, если ваш компилятор поддерживает это.   -  person Peter Cordes    schedule 05.10.2017
comment
Итак, вам нужно выбрать более двух возможных указателей на функции? Сходны ли размеры кода std::vector<std::function<void()>> и boost::variant и т. Д.? Вы могли бы уменьшить штраф за неправильный прогноз, вычислив место назначения перехода вместо его загрузки (если каждый блок машинного кода имеет постоянный размер, тогда std::variant / _4_). Хм, но не совсем потому, что вы будете выполнять вычисления на основе того, что вы загрузили из вектора, поэтому загрузка указателя будет иметь меньшую задержку.   -  person juanchopanza    schedule 05.10.2017
comment
Можете ли вы потратить некоторое время ЦП на сортировку DO B STUFF, чтобы несколько сгруппировать объекты по типу, чтобы код DO C STUFF оставался горячим в кэше uop и предикторах ветвления? Насколько велика base + idx*size в миллионах единиц приблизительно? Или относительно вашего кеша L1I, если вы не настраиваетесь на процессор x86 с кешем uop.   -  person Peter Cordes    schedule 05.10.2017
comment
vecA - нет. Пусть об этом позаботится RAII.   -  person Peter Cordes    schedule 05.10.2017
comment
@Someprogrammerdude Вы читали PSS?   -  person Quentin    schedule 05.10.2017
comment
@Someprogrammerdude Узкое место является теоретическим, это гипотетическая проблема, я действительно изучаю эту проблему и не могу найти никаких материалов в Интернете, у меня нет книг. Конечно, это преждевременная оптимизация, это нарочно! Я считаю, что это не что-то такое ужасное - спросить сверху вниз.   -  person NicoBerrogorry    schedule 05.10.2017
comment
@PeterCordes Большое спасибо за ваши вопросы, все они были очень проницательными. Настройка и профилирование будут выполнены после того, как я узнаю, что делаю это самым быстрым способом, о котором можно думать в стандарте C ++. Это гипотетическая проблема, я не предполагал, что люди все равно будут голосовать против. Я явно вовлечен в этот вопрос и провел все предыдущие исследования, которые мог! Есть один ясный вопрос, иди. Может я ошибаюсь. Большое спасибо за помощь. Это дало мне немного больше взглядов на то, что я буду делать после того, как решу эту проблему, чтобы улучшить ее еще больше.   -  person NicoBerrogorry    schedule 05.10.2017
comment
@PeterCordes Ваш второй и третий комментарии больше похожи на решение! Я действительно думал о создании пулов из однотипных объектов. Это добавило бы накладных расходов к содержащему классу, у которого невероятное количество экземпляров. Может быть, это можно сделать с помощью внешнего контейнера, может быть, внешней карты? И да, я могу потратить некоторое время на сортировку vecA. DO B STUFF такой же большой, как теорема об отдельной оси, или меньше, так как я посмотрю, смогу ли я в ближайшее время изучить лучший алгоритм.   -  person NicoBerrogorry    schedule 05.10.2017
comment
@Quentin В моем контейнере невероятное количество экземпляров, в любом случае проблема не в этом. Я не должен, но чтобы ответить на ваши вопросы, я не хочу накладывать расходы на умный указатель, вот и все.   -  person NicoBerrogorry    schedule 05.10.2017
comment
@NicoBerrogorry: Вы говорите: профилирование будет выполнено после того, как я узнаю, что делаю это самым быстрым способом, о котором можно ДУМАТЬ в c ++ Производительность компьютера не работает таким образом. Контекст / размер проблемы могут легко изменить самый быстрый способ. Кеши, история предсказателей ветвлений и то, что уже находится в нерабочем конвейере ЦП, может влиять по-разному по-разному. Хотя, вероятно, реализация в этом вопросе никогда не будет оптимальной, поскольку она имеет два уровня косвенности (вектор указателей, а затем каждый объект имеет указатель на свою vtable).   -  person NicoBerrogorry    schedule 05.10.2017
comment
@PeterCordes Итак, предстоит проделать большую работу, чтобы решить, какая реализация лучше всего, и в целом нет лучшего способа сделать это. Теперь я вижу, что мне нужно изучить очень много процессоров. Я также должен изучить бесплатное / дешевое программное обеспечение для профилирования. Я считаю, что правильный подход к этому сейчас - реализовать это с помощью виртуального наследования, которое является решением, предоставляемым языком, и тем временем исследовать другие возможные реализации для моего личного набора инструментов, поэтому в конечном итоге, когда появится узкое место, у меня будет множество доступных подходов. Тай Питер.   -  person Peter Cordes    schedule 05.10.2017
comment
@NicoBerrogorry: На самом деле я думаю, потому что вам не нужен какой-либо определенный порядок между объектами разных классов, отдельные контейнеры для отдельных классов, с невиртуальным наследованием, если вы вообще наследуете, - это выход. Это может сделать исходный код больше, но во время выполнения вы позволяете компилятору вывести определение типа из цикла (и полностью его оптимизировать). Встраивание _1_ в цикл над _2_ намного лучше, чем любой вид косвенного вызова функции. Он может даже автоматически векторизовать с помощью SSE / AVX: godbolt.org/g/tL95NX   -  person NicoBerrogorry    schedule 05.10.2017
comment
Ты потрясающий Питер, ты дал мне несколько месяцев на изучение. Лучшее решение для меня - это комментарий выше. Вы можете вставить это в ответ, чтобы я мог принять это, в противном случае я могу написать здесь сборник каждой части помощи, объясняя, почему ответ juanchopanza не так хорош, и т. Д. Скажите мне, что вы предпочитаете.   -  person Peter Cordes    schedule 05.10.2017
comment
Возможно, я немного увлекся написанием этого ...: P Кроме того, теперь у меня есть ошибки автоматической векторизации skylake-avx512, которые нужно отправить в gcc и clang. : / Глупые компиляторы.   -  person NicoBerrogorry    schedule 06.10.2017
comment
Вы упоминаете, что такие классы, как _1_, не обязательно вносят какие-либо накладные расходы по сравнению, скажем, с использованием необработанных указателей в контейнере. К сожалению, обычно это неверно, потому что такие типы предотвращают различные оптимизации на основе характеристик типов, которые контейнеры используют для пропуска определенных операций. полностью (например, уничтожение тривиально разрушаемых типов) и оптимизация других (например, превращение копии объекта за объектом в _2_).   -  person Peter Cordes    schedule 06.10.2017


Ответы (2)


Как сказано в первом комментарии, у вас есть проблема XY. Сортировка / переупорядочение в порядке, и у вас много объектов, а не огромное количество разных классов, и нет необходимости поддерживать типы, о которых ваш код не знает во время компиляции. Полиморфизм + виртуальное наследование - неправильный выбор.

Вместо этого используйте N разных контейнеров, по одному для каждого типа объекта, без косвенного обращения. Разрешить компилятору встроить B::Update() в цикл по всем B объектам намного лучше. (Для тривиального примера увеличения одного члена int, приведенного ниже, мой статический анализ производительности при просмотре asm показывает, что он примерно в 24 раза быстрее на Skylake с горячими данными в кеше L1D. Автоматическая векторизация AVX2 по сравнению с call в цикле - это действительно то, что огромный.)

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


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

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

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


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

Виртуальная отправка работает только с уровнем косвенности (например, вектор указателей, как вы используете), что означает, что вам нужно каким-то образом управлять указанными объектами, например выделив их из vector<B> poolB и vector<C> poolC. Хотя я не уверен, что большинство реализаций vector<> используют realloc(), когда им нужно расти; у new/delete API нет realloc, поэтому vector может фактически копировать каждый раз, когда он растет, вместо того, чтобы пытаться расширить существующее выделение на месте. Проверьте, что делает ваша реализация C ++, поскольку она может показаться отстойной по сравнению с тем, что вы можете сделать с помощью malloc / realloc.

И, кстати, должно быть возможно выполнять _16 _ / _ 17_ с RAII без дополнительных накладных расходов на выделение / освобождение, если все ваши классы тривиально разрушаемы. (Но обратите внимание, что unique_ptr может помешать другим оптимизациям для использования вектор указателей). std::unique_ptr предупреждает, что UB уничтожает его через указатель на базовый класс, так что вам, возможно, придется свернуть свой собственный. Тем не менее, в gcc на x86-64 sizeof(unique_ptr<class C>) всего 8, поэтому он имеет только один член-указатель. Но что бы то ни было, раздельное размещение миллиардов крошечных объектов - отстой, так что не делайте этого в первую очередь.


Сравнение накладных расходов:

Если все объекты одинакового размера, тогда вы действительно хотите перебирать объекты, а не указатели на объекты. Это позволило бы избежать лишнего следа в кэше вектора указателей и избежать дополнительной задержки при поиске указателей, которую неупорядоченное выполнение должно скрывать, чтобы загружать исполнительные блоки. ' т. См. Продолжение @ BeeOnRope: Непрерывное хранилище полиморфных типов. (Также более старые вопросы и ответы: C ++ полиморфизм объекта в массиве ).

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

C ++ 17 std::visit с std::variant<B, C> (с использованием невиртуального наследования для B и C), кажется, дает вам отправку на основе внутреннего enum. std::visit использует свою собственную таблицу переходов для диспетчеризации, даже с двумя возможными типами, вместо того, чтобы встраивать их оба и использовать условную ветвь. Он также должен постоянно проверять состояние "неинициализировано". Вы можете получить хороший код , если вручную обойдете это с помощью B *tmp = std::get_if<B>(&my_variant) и __builtin_unreachable(), чтобы сообщить gcc, что nullptr невозможен. Но в этот момент вы можете просто запустить свой собственный struct polymorph { enum type; union { B b; C c; }; }; (с невиртуальными функциями), если вам не нужно «неинициализированное» состояние. Связано: C ++ полиморфизм объекта в массиве.

В этом случае у вас есть только одна функция, поэтому вы можете поместить указатель на функцию внутри каждого объекта как члена. Вроде void (*m_update)(A* this_object). В вызывающей программе передайте указатель на объект как void* или A*, поскольку это функция, не являющаяся членом. Реализация функции будет reinterpret_cast<C*>(this_object). (Не dynamic_cast: мы сами занимаемся диспетчеризацией, а не используем C ++).

Если вы хотите использовать B и C в других контекстах, где член указателя на функцию будет бесполезно занимать место, вы можете сохранить указатели на функции в отдельном контейнере, а не в базовом классе. Так было бы for(i=0..n) funcptrs[i]( &objects[i] );. Пока ваши контейнеры не рассинхронизируются, вы всегда передаете указатель на функцию, которая знает, что с ней делать. Используйте это с union {B b; C c} objects[] (или vector<union>).

Вы можете использовать void*, если хотите, особенно если вы создаете отдельный массив указателей на функции. Тогда членам союза не нужно наследовать от общей базы.

Вы можете использовать std::function<> для хранения указателей на функции-члены экземпляра, но в gcc x86-64 это 32-байтовый объект. Для вашего места в кэше лучше использовать только 8-байтовые обычные указатели на функции и писать код, который знает, что нужно передавать явный указатель, эквивалентный указателю this.

Размещение указателя функции в каждом объекте может занять больше места, чем enum или uint8_t, в зависимости от текущего размера / выравнивания. Небольшой целочисленный индекс в таблице указателей функций может уменьшить размер каждого экземпляра ваших объектов по сравнению с элементом указателя, особенно для 64-битных целей. Меньшие объекты могут легко стоить пары дополнительных инструкций для индексации массива указателей на функции и, возможно, более высокого штрафа за неправильный прогноз из-за дополнительного разыменования указателя. Промахи памяти / кеша часто являются узким местом.

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


Виртуальная диспетчеризация C ++:

Я взглянул на сгенерированный компилятором asm (gcc и clang, нацеленный на x86-64), чтобы найти несколько способов сделать это.

clang и gcc автоматически векторизуют цикл по vecB с помощью AVX2 для параллельной обработки 8 int элементов, поэтому, если вы не ограничиваете пропускную способность памяти (например, горячую в кэше L1D), этот цикл может увеличиваться на 8 элементов за такт. Этот цикл выполняется так же быстро, как и цикл над vector<int>; все встраивается и оптимизируется, и это просто приращение указателя.

class A{
    public:
    virtual void Update() = 0; // A is so pure *¬*
};

struct C : public A {
    int m_c = 0;
    public:
    void Update() override final
    {  m_c++;  }
};
int SC = sizeof(C);  // 16 bytes because of the vtable pointer

C global_c;  // to instantiate a definition for C::Update();

// not inheriting at all gives equivalent asm to making Update non-virtual
struct nonvirt_B //: public A
{
    int m_b = 0;
    void Update() //override final
    {  m_b++;  }
};
int SB = sizeof(nonvirt_B);  // only 4 bytes per object with no vtable pointer

void separate_containers(std::vector<nonvirt_B> &vecB, std::vector<C> &vecC)
{
    for(auto &b: vecB)        b.Update();
    for(auto &c: vecC)        c.Update();   
}

Цикл над vecC может выполнять только 1 элемент за такт, потому что каждый объект имеет размер 16 байтов (8-байтовый указатель vtable, 4 байта int m_c, 4 байта заполнения до следующей границы выравнивания, потому что указатель имеет Требование выравнивания 8B.) Без final компилятор также проверяет указатель vtable, чтобы увидеть, действительно ли это C, прежде чем использовать встроенный C::Update(), в противном случае он отправляет. Это похоже на то, что вы получили бы за цикл по struct { int a,b,c,d; } vecC[SIZE];, выполняя vecC[i].c++;

final разрешена полная девиртуализация, но наши данные смешаны с указателями vtable, поэтому компиляторы просто делают скаляр add [mem], 1, который может работать только с частотой 1 за такт (узким местом является пропускная способность хранилища 1 за такт, независимо от размера хранилища, если он горячий в кеше L1D ). Это в основном побеждает SIMD в этом примере. (С -march=skylake-avx512, gcc и clang выполняют нелепое перемешивание или сбор / разброс, которое даже медленнее, чем скаляр, вместо простой загрузки / восстановления всего объекта и добавления с помощью вектора, который изменяет только член int. Это разрешено, потому что он не содержит любые изменчивые или атомарные элементы, и будет запускать 2 за такт с AVX2 или 4 за такт с AVX512.) Наличие ваших объектов размером до 12 байт является серьезным недостатком, если они маленькие, а у вас их много.

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

Поскольку вы упомянули теорему о разделяющей оси, надеюсь, вы не планируете хранить float x,y пары в каждом объекте. Массив структур в основном отстой для SIMD, потому что для использования x с y для одного и того же объекта требуется много перетасовки. Вам нужно std::vector<float> x, y или подобное, чтобы ваш ЦП мог загрузить 4 значения x в регистр и 4 значения y в другой регистр. (Или 8 одновременно с AVX).

См. Слайды: SIMD в Insomniac Games (GDC 2015), где вы узнаете, как структурировать данные для SIMD, и о некоторых других дополнительных вещах. См. Также вики-страницу с тегами sse для получения дополнительных руководств. Кроме того, в вики-странице по тегам x86 есть множество материалов по низкоуровневой оптимизации x86. Даже если вы ничего не векторизуете вручную, с отдельными массивами для x и y есть хороший шанс, что компилятор сможет автоматически векторизовать за вас. (Посмотрите на вывод asm или сравнительный анализ gcc -O3 -march=native против gcc -O3 -march=native -fno-tree-vectorize). Вам может понадобиться -ffast-math для некоторых видов векторизации FP.

Написав это так, как вы это делаете в вопросе, с виртуальным наследованием и


Союз взлома:

Получаем этот цикл из clang5.0 -O3 -march=skylake

std::vector<A*> vecA{};

void vec_virtual_pointers() {
    for(auto a: vecA)
        a->Update();
}

Таким образом, последний указатель на функцию находится в конце цепочки из трех зависимых нагрузок. Выполнение вне порядка позволяет перекрываться между итерациями (если ветвь предсказывает правильно), но это приводит к большим накладным расходам только в общих инструкциях для внешнего интерфейса, а также в штрафах за неверное предсказание. (call [m] - 3 мупа, поэтому сам цикл составляет 8 мопов, и может выдавать только один за 2 цикла в Skylake. Вызов / возврат также имеет накладные расходы. Если вызываемый объект не совсем тривиален, мы, вероятно, не будем узким местом в магазине. пересылка для нажатия / выталкивания адреса возврата. Цикл с вызовом функции быстрее чем пустой цикл. callee крошечный, как здесь.)

   # rbx = &vecA[0]
.LBB2_1:                         # do{
    mov     rdi, qword ptr [rbx]   # load a pointer from the vector (will be the this pointer for Update())
    mov     rax, qword ptr [rdi]   # load the vtable pointer
    call    qword ptr [rax]        # memory-indirect call using the first entry in the vtable
    add     rbx, 8                 # pointers are 8 bytes
    cmp     r14, rbx
    jne     .LBB2_1              # }while(p != vecA.end())

Clang определяет C :: Update ():

Если для этого нужно было установить какие-либо константы перед вычислением, было бы еще дороже, если бы это не было встроено. Таким образом, с виртуальной диспетчеризацией это, вероятно, выполняется примерно по одному члену на 3-5 тактов вместо примерно 1 участника за такт на Skylake. (Или 8 участников за такт с AVX2 для невиртуальных class B, что не тратит впустую пространство и обеспечивает хорошую работу автоматической векторизации.) http://agner.org/optimize/ говорит, что Skylake имеет пропускную способность 80_ на каждые 3 такта, так что, допустим, 24-кратная потеря производительности при горячих данных в кэше L1D. Конечно, разные микроархитектуры будут разными. См. Вики-страницу по тегам x86 для получения дополнительной информации о производительности x86.

C::Update():                         # @C::Update()
    inc     dword ptr [rdi + 8]
    ret

Вероятно, вам никогда не следует использовать это, но из asm видно, что он будет работать на x86-64 с clang и gcc. Я сделал массив объединений и перебрал его:


std::function из #include <functional>

У всех A B и C есть свои vtable в начале, поэтому я думаю, что в целом это сработает. Мы предполагаем, что в основном то же самое, только на один шаг поиска указателя меньше. (Я использовал статический массив вместо вектора, так как я сохранял простоту и стиль языка Си, разбирая, что нужно преобразовать.)

union upoly {
    upoly() {}   // needs an explicit constructor for compilers not to choke
     B b;
     C c;
} poly_array[1024];

void union_polymorph() {
    upoly *p = &poly_array[0];
    upoly *endp = &poly_array[1024];
    for ( ; p != endp ; p++) {
        A *base = reinterpret_cast<A*>(p);
        base->Update();           // virtual dispatch
    }
}

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

    lea     rdi, [rbx + poly_array]       ; this pointer
    mov     rax, qword ptr [rbx + poly_array]   ; load it too, first "member" is the vtable pointer
    call    qword ptr [rax]
    add     rbx, 16                       ; stride is 16 bytes per object
    cmp     rbx, 16384                    ; 16 * 1024
    jne     .LBB4_1

Он может содержать любые вызываемые объекты. Но он имеет даже больше накладных расходов, чем отправка vtable, потому что он может находиться в состоянии ошибки при использовании. Таким образом, внутренний цикл должен проверять каждый экземпляр на это и ловить, если это так. Кроме того, sizeof(std::function<void()>); составляет 32 байта (в x86-64 System V ABI).


Если вам действительно нужна виртуальная диспетчеризация, одним из способов ускорения диспетчеризации для одного и того же виртуального метода в списке объектов различных производных типов является использование того, что я назову тип-unswitching < / em>.

Таким образом, существует до трех call инструкций, если указатель не равен nullptr. Это выглядит намного хуже, чем виртуальная рассылка.

#include <functional>
// pretty crappy: checks for being possibly unset to see if it should throw().
std::vector<std::function<void()>> vecF{};
void vec_functional() {
    for(auto f: vecF)     f();
}

                                # do {
.LBB6_2:                                # =>This Inner Loop Header: Depth=1
    mov     qword ptr [rsp + 16], 0       # store a 0 to a local on the stack?
    mov     rax, qword ptr [rbx + 16]
    test    rax, rax
    je      .LBB6_5           # throw on pointer==0  (nullptr)
    mov     edx, 2            # third arg:  2
    mov     rdi, r14          # first arg: pointer to local stack memory (r14 = rsp outside the loop)
    mov     rsi, rbx          # second arg: point to current object in the vector
    call    rax               # otherwise call into it with 2 args
    mov     rax, qword ptr [rbx + 24]    # another pointer from the std::function<>
    mov     qword ptr [rsp + 24], rax    # store it to a local
    mov     rcx, qword ptr [rbx + 16]    # load the first pointer again
    mov     qword ptr [rsp + 16], rcx
    test    rcx, rcx
    je      .LBB6_5           # check the first pointer for null again (and throw if null)
    mov     rdi, r14
    call    rax               # call through the 2nd pointer
    mov     rax, qword ptr [rsp + 16]
    test    rax, rax
    je      .LBB6_12          # optionally skip a final call
    mov     edx, 3
    mov     rdi, r14
    mov     rsi, r14
    call    rax
.LBB6_12:                               #   in Loop: Header=BB6_2 Depth=1
    add     rbx, 32
    cmp     r15, rbx
    jne     .LBB6_2

.LBB6_13:                       # return
    add     rsp, 32
    pop     rbx
    pop     r14
    pop     r15
    ret

.LBB6_5:
    call    std::__throw_bad_function_call()
    jmp     .LBB6_16
    mov     rdi, rax
    call    __clang_call_terminate

Немного по-другому выглядит clang -stdlib=libc++ вместо значения по умолчанию libstdc++. (https://libcxx.llvm.org/). Но все еще три call инструкции во внутреннем цикле с условными операторами, которые нужно пропустить или выбросить.

Если генерация кода не сильно отличается для разных типов function<T>, вероятно, даже не стоит искать в нем указатели на функции-члены, если вы можете написать более эффективную альтернативу.

Если вам нужна была какая-то рассылка, как того требует заголовок

person Peter Cordes    schedule 06.10.2017
comment
@BeeOnRope: Спасибо, исправлено. - person BeeOnRope; 09.10.2017
comment
Меня интересуют упомянутые вами _1_ и хакерские хаки union. В частности, я хотел бы узнать о хорошем способе непрерывного хранения полиморфных объектов и задать вопрос по теме. - person Peter Cordes; 09.10.2017
comment
Питер, извините за то, что так долго принимал ответ, я был очень занят. Меня поражает и мотивирует сложность проблемы. Я уже дважды прочитал ваш ответ, и есть еще кое-что, что я не совсем понимаю в коде сборки, о котором я на данный момент знаю очень общие. Я должен поблагодарить вас за такой полный анализ и за всю ту свободную любовь, которую вы вложили в это. Решение с отдельными контейнерами легко применимо к моей проблеме, вот что я буду использовать. И я изучу все необходимое, чтобы полностью понять ваш ответ. Ваше здоровье. - person BeeOnRope; 09.10.2017
comment
Я только что наткнулся на PolyContainer, в котором реализованы некоторые из материал, который вы упомянули: в частности, раздельное хранилище по типу. В нем есть некоторые интересные вещи, такие как то, что они называют восстановлением типа, что означает, что вызывающий предоставляет полный тип объекта на сайте вызова, а затем вы можете перебирать все объекты этого типа с компилятором, зная производный тип, что может очень помочь оптимизация. Бенчмарки тоже. - person NicoBerrogorry; 10.10.2017
comment
Вы предлагаете вместо растрового изображения использовать контейнеры массива индексов. Еще лучше, удалите уровень косвенного обращения, скопировав указатели из _1_ в отдельные контейнеры _2_ и _3_. После прохода сортировки вы выполняете _4_. (Это может удвоить требования к хранилищу, если бы вы использовали 32-битные индексы, но теперь вам нужны 64-битные указатели.) - person BeeOnRope; 13.08.2019

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

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

В случае двух производных классов B и C вы можете просто использовать растровое изображение для хранения информации о типе. Вот пример реализации с использованием типов A, B, C из кода в вопросе:

Здесь typecode - это общее поле в A, которое определяет тип объекта, 0 для B и 1 для C. Что-то подобное необходимо, чтобы сделать возможной категоризацию по типу (это не может быть виртуальный вызов, поскольку непредсказуемый вызов - это то, чего мы пытаемся избежать в первую очередь).

void virtual_call_unswitch(std::vector<A*>& vec) {

    // first create a bitmap which specifies whether each element is B or C type
    std::vector<uint64_t> bitmap(vec.size() / 64);

    for (size_t block = 0; block < bitmap.size(); block++) {
        uint64_t blockmap = 0;
        for (size_t idx = block * 64; idx < block * 64 + 64; idx++) {
            blockmap >>= 1;    
            blockmap |= (uint64_t)vec[idx + 0]->typecode_ << 63;
        }
        bitmap[block] = blockmap;
    }

    // now loop over the bitmap handling all the B elements, and then again for all the C elements

    size_t blockidx;
    // B loop
    blockidx = 0;
    for (uint64_t block : bitmap) {
        block = ~block;
        while (block) {
            size_t idx = blockidx + __builtin_ctzl(block);
            B* obj = static_cast<B*>(vec[idx]);
            obj->Update();
            block &= (block - 1);
        }
        blockidx += 64;
    }

    // C loop
    blockidx = 0;
    for (uint64_t block : bitmap) {
        while (block) {
            size_t idx = blockidx + __builtin_ctzl(block);
            C* obj = static_cast<C*>(vec[idx]);
            obj->Update();
            block &= (block - 1);
        }
        blockidx += 64;
    }
}

Слегка оптимизированная версия вышеупомянутого показывает примерно 3,5-кратное ускорение для непереключаемой версии по сравнению с обычным виртуально диспетчерским циклом, при этом виртуальная версия синхронизируется примерно за 19 циклов на отправку, а непереключаемая версия - примерно в 5,5. Полные результаты:

VirtualDispatchTrue - это простой цикл, вызывающий Update() указатель типа A:

-----------------------------------------------------------------------------
Benchmark                                      Time           CPU Iterations
-----------------------------------------------------------------------------
BenchWithFixture/VirtualDispatchTrue       30392 ns      30364 ns      23033   128.646M items/s
BenchWithFixture/VirtualDispatchFakeB       3564 ns       3560 ns     196712   1097.34M items/s
BenchWithFixture/StaticBPtr                 3496 ns       3495 ns     200506    1117.6M items/s
BenchWithFixture/UnswitchTypes              8573 ns       8571 ns      80437   455.744M items/s
BenchWithFixture/StaticB                    1981 ns       1981 ns     352397    1.9259G items/s

VirtualDispatchFakeB приводит указатель к B* (независимо от базового типа) перед вызовом Update(). Поскольку B::Update() является final, компилятор может полностью де-виртуализировать и встроить вызов. Конечно, это совсем не то, что нужно: он обрабатывает любые C объекты как B и поэтому вызывает неправильный метод (и полностью UB), но он здесь, чтобы оценить, насколько быстро вы можете вызывать методы для вектора указателей. если бы все объекты были одного и того же статически известного типа.

for (A *a : vecA) {
    a->Update();
}

StaticBPtr перебирает std::vector<B*>, а не std::vector<A*>. Как и ожидалось, производительность такая же, как у приведенного выше «фальшивого» кода B, поскольку цель для Update() статически известна и полностью встраиваема. Это здесь как проверка работоспособности.

for (A *a : vecA) {
    ((B *)a)->Update();
}

UnswitchTypes - это трюк с отключением переключения, описанный выше.

StaticB перебирает std::vector<B>. То есть, непрерывно размещено B объекта, а не вектор указателей на объекты B. Это устраняет один уровень косвенности и показывает что-то вроде лучшего случая для этого макета объекта 2.

Доступен полный исходный код, который является общественным достоянием.

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

Побочные эффекты и порядок

Поддерживает два типа

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

Фиксированное количество поддерживаемых типов

Это ограничение довольно легко снять. Во-первых, можно расширить растровый подход. Например, для поддержки 4 типов могут быть созданы два похожих растровых изображения, для которых соответствующие биты каждого растрового изображения по существу для 2-битового поля, кодирующего тип. Циклы похожи, за исключением того, что во внешнем цикле они & и ~ растровые изображения вместе, как для всех 4 типов. Например.:

Другой подход - вообще не использовать растровые изображения, а просто хранить массив индексов для каждого типа. Каждый индекс в массиве указывает на объект этого типа в главном массиве. По сути, это однопроходная поразрядная сортировка по коду типа. Это, вероятно, делает часть сортировки типов немного медленнее, но потенциально ускоряет логику итерации цикла (элементы x & (x - 1) и ctz исчезают за счет другого косвенного обращения).

// type 1 (code 11)
for (size_t i = 0; i < bitmap1.size(); i++) {
        block = bitmap1[i] & bitmap2[i];
        ...
}


// type 2 (code 01)
for (size_t i = 0; i < bitmap1.size(); i++) {
        block = ~bitmap1[i] & bitmap2[i];
        ...
}

...

Приведенный выше код поддерживает фиксированное количество типов, известных во время компиляции (а именно, B и C). Если введен новый тип, приведенный выше код либо сломается, либо не вызовет Update() для этих новых типов.

PolyCollection

Однако добавить поддержку неизвестных типов несложно. Просто сгруппируйте все неизвестные типы, а затем только для этих типов выполните полную виртуальную диспетчеризацию внутри цикла (т. Е. Вызовите Update() непосредственно на A*). Вы заплатите полную цену, но только за типы, которые вы явно не поддерживали! Таким образом, этот метод реализует универсальность механизма виртуальной диспетчеризации.

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

Самый важный вопрос, который нам нужно задать в первую очередь: Почему? Почему вам нужны альтернативы? Какую актуальную проблему вы пытаетесь решить этим? Вы действительно измерили, что это проблема? Узкое место в вашей системе? Потому что, если вы этого не сделали, не делайте этого! Это пахнет преждевременной оптимизацией, а в целом они очень плохи. И, конечно же, прочтите проблему XY.

Он поддерживает полиморфизм на основе virtual методов, а также полиморфизм на основе функциональных объектов и полиморфизм на основе типа «утка». Он реализует «отключение», описанное выше, путем разделения различных типов объектов в хранилище: таким образом, он не сохраняет порядок вставки между объектами разных типов. Если он соответствует вашим потребностям, это может быть уже готовое решение.

1 На самом деле вам нужен только один цикл на группу типов, которые все используют одну и ту же реализацию виртуального метода, хотя это может быть трудно реализовать в общем виде, поскольку это информация недоступна. Например, если классы Y и Z являются производными от X, но ни один из них не отменяет реализацию некоторого виртуального метода из X, тогда все X, Y и Z могут обрабатываться одним и тем же циклом.


2 Под «компоновкой объекта» я подразумеваю B объекты, у которых все еще есть виртуальные методы и, следовательно, vtable. Если вы удалите все виртуальные методы и избавитесь от vtable, все пойдет намного быстрее, поскольку компилятор затем векторизует добавление к компактно расположенным полям. Vtable все портит.

Ограничения

person BeeOnRope    schedule 09.10.2017
comment
Кстати, использование vector<A*> может быть лучше, потому что оно укорачивает цепочку зависимостей vector<B*> / vector<C*>, переносимую циклом, и цепочку shift / OR dep при сортировке. Это позволяет больше перекрывать OOOE между блоками. (Кроме того, это не ужасно на 32-битных процессорах, на случай, если кого-то волнует ARM32 или что-то в этом роде.) - person Peter Cordes; 09.10.2017
comment
Я не слежу за комментарием о цепочке зависимостей _1 _ / _ 2_? Есть только одна переносимая цепочка зависимостей через blsr, остальные цепочки свисают с этой цепочки и, насколько я могу видеть, короткие. В любом случае я не понимаю, как помогает использование меньшего размера блока. Основная причина использовать больший размер блока - уменьшить количество ошибочных прогнозов при выходе из блока. Что касается первоначальной генерации растровых изображений, то на моем компьютере она выполняется на скорости ›4 IPC, так что здесь не так много возможностей для улучшения. @PeterCordes - person Peter Cordes; 09.10.2017
comment
Ой, tzcnt не переносится по циклу, я представлял себе что-то глупое, например, увеличение счетчика битовых позиций на blsr и использование этого для очистки бита, а не blsr. Хороший аргумент в пользу меньшего количества ошибочных прогнозов с большими блоками, поскольку это несложная ветвь. Вы можете делать это без веток, каждый раз с load + cmov, но это будет слишком много работы, которую каждый раз выбрасывают. Для цикла битовой карты раздела, я думаю, типично, что циклы gcc являются узким местом на интерфейсе, потому что по умолчанию он вообще не разворачивается. Однако я не смотрел на ассемблер. - person BeeOnRope; 09.10.2017
comment
@PeterCordes Верно, выход из внутреннего цикла трудно предсказать (т.е. он всегда будет предсказан неверно), но в этом случае это происходит только каждые ~ 32 итерации. Да, я пробовал подход tzcnt, и он может окупиться, если количество итераций внутреннего цикла (т. Е. Количество повторений растрового изображения) невелико, например, 5. Здесь оно составляет около 32, поэтому неверный прогноз окупается. сам с более простым кодом. - person Peter Cordes; 09.10.2017
comment
Один из опробованных мной подходов, который может заставить подход cmov работать, - это развернуть внутренний цикл, скажем, 4 раза. Это амортизирует проверку выхода и позволяет использовать стратегию _2_, а не внутренний цикл. Проблема, конечно, в том, что при развертывании _3_ становится равным нулю в середине развернутой части. В некоторых сценариях неправильная вещь не вызывает затруднений (кроме небольшой траты), но здесь она вызывала бы _4_ на _5_, что определенно неверно. Иногда вы можете отменить неправильную вещь в части, следующей за развернутым кодом, но я не вижу здесь выхода. - person BeeOnRope; 09.10.2017
comment
@PeterCordes - хороший аргумент в отношении простого копирования указателей! Это может увеличить объем хранилища более чем в 2 раза - вы могли бы использовать 8-битные или 16-битные индексы, если они являются дельтами (с резервным медленным путем, когда дельта оказывается слишком большой). В 8-битном случае, например, вы можете загрузить 8 индексов с 64-битным чтением, которое в основном устраняет косвенность, за счет некоторого сдвига и маскирования для использования прочитанных 8 байтов за раз. Однако я должен попробовать подход с использованием чистого указателя. - person BeeOnRope; 09.10.2017
comment
Ага, здесь хорошо смотрится ветвистый подход, с развёрткой или без. (Развертывание распространяет шаблон ветвления на несколько ветвей, если вы на самом деле не используете _1_ для проверки того, что осталось кратное 4 битам или что-то в этом роде.) - person BeeOnRope; 09.10.2017
comment
В принципе, вы можете по-прежнему использовать указатели меньшего размера, чем 64-битные: сохранять дельту от первого или предыдущего указателя. Конечно, он может выйти из строя, если два указателя находятся далеко друг от друга, но для типичных распределителей и следов приложений он будет работать (и у вас может быть запасной путь, если он не удастся). - person Peter Cordes; 09.10.2017
comment
В случае OP сами объекты могут быть выделены из одного пула, поэтому вы можете использовать небольшие индексы в _1_ и _2_. Но в этот момент вы можете просто перебрать _3_ и _4_! Да, но если вы иногда освобождаете указатель, то да. Дельта от предыдущего указателя превращает его в обход связанного списка при загрузке, зависящей от данных. Хотя местность хорошая. - person BeeOnRope; 09.10.2017
comment
@PeterCordes - о распространении веток, я на самом деле говорил о другом виде развертывания: без проверок между развернутыми инструкциями (т.е. развернутая часть имеет в значительной степени Bpool). Конечно, это не так, как указано выше! Можно было бы как следует развернуть с чеком после каждого Cpool, но это мало помогает? Я не думаю, что здесь очень помогает распространение ветвления. Если есть шаблон, одна ветвь должна работать нормально (а когда используется глобальная история, подходы в основном эквивалентны, но больше ветвей тратят впустую BTB). - person Peter Cordes; 09.10.2017
comment
Дельта не превращает его в обход связанного списка. Считывание следующей дельты не зависит от предыдущей. Обход связанного списка является плохим (т.е. включает задержку загрузки), потому что следующий указатель хранится в том месте, на которое указывает предыдущий. Это не тот случай, когда в tzcnt, blsr, add [mem], 1 все указатели расположены рядом. - person BeeOnRope; 09.10.2017
comment
Ах да, опять глупый я. вы делаете vector<B*>, поэтому все еще существует новая зависимость с переносом цикла, но она только при добавлении, а не при загрузке. Хотя это отстой для сбора SIMD. - person BeeOnRope; 09.10.2017
comment
Для сбора SIMD вы можете сделать дельты основанными на элементе в позиции Bidx += Bdeltas[i], а не _2_, где N - ширина вектора (в единицах размера указателя). Затем вы можете загрузить вектор дельт и добавить предыдущие указатели и собрать / разбросать по своему сердцу. Также обратите внимание, что я сказал дельты от первого или предыдущего указателя. У SIMD проблема только с предыдущим. Если вы просто сохраняете все дельты из единственного фиксированного (первого) указателя, проблем не возникает (только подгруппа по отношению к вектору с передачей контрольной точки каждому элементу). - person Peter Cordes; 09.10.2017
comment
Re: шаблоны предсказания ветвлений: Skylake правильно предсказывает выходы из цикла для циклов с 22 или меньшим количеством итераций, но 24 или больше - это почти одно неверное предсказание на цикл. Таким образом, разворачивание может действительно помочь, если существует постоянный шаблон, который слишком длинный, чтобы его можно было уловить. Еще одно преимущество разворачивания с ветками состоит в том, что неиспользованные ветки лучше подходят для внешнего интерфейса, чем взятые. Но я думаю, что cur - N / проверка для настройки развернутого цикла только с одной ветвью цикла еще лучше. - person BeeOnRope; 09.10.2017
comment
Единственное реальное преимущество предыдущего подхода состоит в том, что дельты могут иметь тенденцию быть меньше, если есть некоторая тенденция в адресах (например, увеличение количества адресов из элементов, расположенных последовательно). В этом случае вы можете уместить свою дельту на более мелкие элементы, если воспользуетесь предыдущим. - person Peter Cordes; 09.10.2017
comment
Да, дельта-сжатие индексов для экономии места в кеше звучит как интересная идея, которую стоит рассмотреть. Хороший момент, что _1_ все равно решит эту проблему. - person BeeOnRope; 09.10.2017
comment
@PeterCordes - ну, здесь условие выхода из цикла совершенно непредсказуемо, но давайте рассмотрим ваше требование в случае, когда оно предсказуемо (выход после фиксированного количества итераций). Вышеупомянутое поведение могло быть справедливым для одной ветви в цикле, но это не обязательно означает, что вы можете экстраполировать это на цикл с несколькими ветвями. В частности, современный Intel, похоже, использует глобальную историю, поэтому разделение ветвей на самом деле не меняет историю, которую видит каждая ветка! - person Peter Cordes; 09.10.2017
comment
То есть, если у вас в цикле 4 ветки вместо 1, каждая ветвь видит в своей истории шаблон других трех ветвей, поэтому необходимая история будет такой же длинной, чтобы правильно спрогнозировать выход. Это отличается от случая фиксированного 32-итерационного цикла. Там вы можете разбить его на два отдельных цикла по 16 итераций, и оба могут быть предсказаны правильно, потому что каждый цикл по-прежнему имеет только одну ветвь, а длина требуемой истории фактически уменьшена. - person BeeOnRope; 09.10.2017
comment
Ах, хороший момент, я не пробовал с незанятой веткой внутри цикла. Таким образом, _1_ настройка для развернутого цикла может иметь еще большее преимущество. Уловка состоит в том, чтобы избежать неверных прогнозов до того, как куча полезной работы попадет в конвейер ... Может быть _2_, поэтому последние 3 очистки происходят после основного цикла, а ветвь перед первой развернутой итерацией неверно прогнозирует, только если было установлено менее 4 бит . - person BeeOnRope; 09.10.2017
comment
Вот крайняя идея (уже ни в коем случае не чистый C ++): вместо использования растрового изображения или списков индексов / указателей просто сгенерируйте фактический код, который будет выполняться как одна большая непрерывная программа. То есть на этапе сортировки сгенерируйте код для popcnt с жестко запрограммированным адресом. В этом случае в значительной степени просто for(setbits=popcnt(block); setbits >= 4; setbits -= 4){ } while(setbits--) {} или _3_. Тогда просто выполняй! Это случай, когда JIT-код, который может запускаться только один раз, все равно может быть быстрее, чем альтернативы. В основном это работает, если длины кода одинаковы / похожи, поскольку в противном случае копии требуется ветвь. - person Peter Cordes; 09.10.2017
comment
Это нормально, только если что-то мешает вам просто использовать отдельные контейнеры для объектов Update() и add (не указатели), поэтому вы можете писать оптимизированные циклы над контейнером смежных sub. Накладные расходы, связанные с этим при изменении контейнера, вероятно, аналогичны управлению отдельными контейнерами. Главное, что он получает, - это чрезвычайно хорошая производительность, когда существует необходимый порядок между объектами B и C, поэтому вы не можете просто делать все-B, а затем все-C. - person BeeOnRope; 09.10.2017
comment
Да точно. Это полезно, только если порядок имеет значение или из-за каких-то внешних ограничений, которые вы не можете изменить, вы получаете массив указателей. - person Peter Cordes; 09.10.2017
comment
<Сильный> Источник для множественных путей делаю это + asm из x86-64 clang 5. 0 в обозревателе компилятора Godbolt. Вы можете переключить его на архитектуру gcc или не x86. - person BeeOnRope; 09.10.2017