Невероятно быстрые делегаты C++

ОГЛАВЛЕНИЕ

Реализация библиотеки делегатов, которая может работать быстрее, чем "Наиболее быстрые делегаты C++ " и полностью совестима со стандартами C++.

Введение

Дон Клагстон предложил подход к делегатам (далее называемым FastDelegate), который требует такого же вызывающего кода, что и создаваемый для вызова с помощью указателя на член-функцию в простейшем случае (он описал, почему некоторые компиляторы порождают более сложный код для полиморфных классов и для классов с виртуальным наследованием). Он описал, почему многие другие популярные подходы оказались неэффективными. К сожалению, его подход основан на 'хитром трюке' (как он сказал). Он работает во многих популярных компиляторах, но несовместим со стандартами C++.

Кажется верным, что FastDelegate – это самый быстрый способ. Но мы полагаем, что это заявление требует доказательств, потому что современные оптимизирующие компиляторы C++ делают поразительные вещи. Мы уверены, что boost::function и другие делегаты, основанные на динамическом выделении памяти, являются медленными, но кто сказал, что нет других хороших подходов?

Мы собираемся предложить другой подход, который:

      а) быстрый;

      б) не использует динамическое выделение памяти;

      с) полностью совместим со стандартами C++.

Еще один подход к делегатам

Рассмотрим делегат, получающий один аргумент и не возвращающий никакого значения. Его можно определить следующим образом, используя предпочтительный синтаксис (как boost::function и FastDelegate, наша библиотека поддерживает предпочтительный и совместимый синтаксисы; более подробную информацию смотрите в документации):

delegate<void (int)>

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

class delegate
{
public:
    delegate()
        : object_ptr(0)
        , stub_ptr(0)
    {}

    template <class T, void (T::*TMethod)(int)>
    static delegate from_method(T* object_ptr)
    {
        delegate d;
        d.object_ptr = object_ptr;
        d.stub_ptr = &method_stub<T, TMethod>; // #1
        return d;
    }

    void operator()(int a1) const
    {
        return (*stub_ptr)(object_ptr, a1);
    }

private:
    typedef void (*stub_type)(void* object_ptr, int);

    void* object_ptr;
    stub_type stub_ptr;

    template <class T, void (T::*TMethod)(int)>
    static void method_stub(void* object_ptr, int a1)
    {
        T* p = static_cast<T*>(object_ptr);
        return (p->*TMethod)(a1); // #2
    }
};

Делегат состоит из нетипированного указателя на данные (так как делегат не должен зависеть от типа приемника) и указателя на функцию. Эта функция получает указатель на данные в качестве дополнительного параметра. Он преобразует указатель данных в указатель объекта ('void*', в отличие от указателей-членов может быть благополучно преобразован обратно в указатели объектов: [expr.static.cast], элемент 10) и вызывает требуемую функцию-член.

Когда вы создаете непустой делегат, вы неявно реализуете функцию-заглушку путем получения ее адреса (смотрите строку #1 выше). Это возможно, потому что стандарт C++ разрешает использовать указатель на член или указатель на функцию в качестве параметра шаблона ([temp.params], элемент 4):

SomeObject obj;
delegate d = delegate::from_member<SomeObject,
              &SomeObject::someMethod>(&obj);

Теперь 'd' содержит указатель на функцию-заглушку, связываемый с 'someMethod' во время компиляции. Хотя был задан указатель на член, вызов в строке #2 выполняется так же быстро, как и прямой вызов метода (потому что его значение известно во время компиляции).

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

d(10); // вызов SomeObject::someMethod
       // для obj и передача им 10 в качестве параметра

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


Измерение производительности

Мы измеряли производительность вызова делегата с различными комбинациями виртуальных/невиртуальных методов, с различным числом аргументов и с различными видами наследования. Также мы измеряли производительность делегатов, связанных с функцией и статическим методом. Мы сравнили производительность FastDelegate с нашим подходом, используя компиляторы MS Visual C++ 7.1 и Intel C++ 8.0 на процессоре P4 Celeron.

В сложных случаях использование функции-заглушки может вызвать значительные непроизводительные затраты (до 5,5 раз в MSVC и до 2,4 раз в Intel). Но иногда самые быстрые делегаты оказываются медленнее (до 15% в Intel и немного на MSVC). Они всегда медленнее на статических членах и на свободных функциях. Как это возможно?

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

Наиболее быстрые делегаты вынуждены использовать соглашение о вызовах 'thiscall'. Наши делегаты могут использовать любое соглашение о вызовах (за исключением 'thiscall'), включая '__fastcall'. Это позволяет передавать до двух аргументов размера int через регистры ('thiscall' передает только указатель 'this' через ECX).

На самом деле существует простой способ сделать ваш код, основанный на делегатах, очень быстрым (если это вам действительно нужно):

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

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

Копирование и сравнение делегатов

Производительность конструктора копирования не существенна для обоих типов делегатов (в отличие от делегатов, основанных на динамическом распределении памяти, таких как boost::function). Однако наши делегаты могут копироваться немного быстрее, так как они занимают меньше места.

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

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

Мы знаем только одну причину, почему вам может потребоваться сравнивать делегаты. Это синтаксис события, такой как в C#. Он выглядит привлекательно, но не может быть реализован без динамического распределения памяти. Более того, в C++ он может работать неверно в ряде случаев. Мы бы предложили другой механизм распространения событий, более подходящий для C++, по нашему мнению.


Переносимость

Хотя этот подход совместим со стандартами C++, к сожалению, он не работает в некоторых компиляторах. Нам не удалось скомпилировать тестовый код в Borland C++. Предпочтительный синтаксис не работает в MSVC 7.1, хотя он успешно компилирует boost::function с таким же синтаксисом.

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

Библиотека событий

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

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

Эта библиотека предоставляет две сущности: event_source (это упрощенный аналог boost::signal) и event_binder (аналог boost::signals::scoped_connection). Обычно инициатор события содержит event_source и клиент события содержит event_binder. Связь между инициатором и клиентом существует, пока оба - event_source и event_binder  - существуют.

Вы не можете использовать анонимное соединение. Фактически в Boost вы можете использовать его двумя способами:

  1. Вы абсолютно уверены, что клиент события существует дольше, чем инициатор события.
  2. Вы должны использовать boost::signals::trackable как базовый класс клиента события (можно реализовать аналог в нашей библиотеке, но мы не уверены, что это хорошая идея).

Вы можете применять это в делегатах с многократным приведением в стиле C#, но здесь возникает другая проблема: вы должны поддерживать пары действий (подписка и отмена подписки), но их правильность нельзя проверить во время компиляции.

Более подробную информацию смотрите в документации.

Заключение

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

Загрузить исходный код - 35.7 Kb