Как ускорить динамическую отправку на 20% с помощью вычисленных gotos в стандартном C ++

Прежде чем проголосовать против или начать говорить, что gotoing является злом и устаревшим, пожалуйста, прочтите обоснование того, почему он жизнеспособен в данном случае. Прежде чем пометить его как дубликат, прочтите вопрос полностью.

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

Рассмотрим (очень) простую виртуальную машину, подобную этой:

#include <iostream>

enum class Opcode
{
    HALT,
    INC,
    DEC,
    BIT_LEFT,
    BIT_RIGHT,
    RET
};

int main()
{
    Opcode program[] = { // an example program that returns 10
        Opcode::INC,
        Opcode::BIT_LEFT,
        Opcode::BIT_LEFT,
        Opcode::BIT_LEFT,
        Opcode::INC,
        Opcode::INC,
        Opcode::RET
    };
    
    int result = 0;

    for (Opcode instruction : program)
    {
        switch (instruction)
        {
        case Opcode::HALT:
            break;
        case Opcode::INC:
            ++result;
            break;
        case Opcode::DEC:
            --result;
            break;
        case Opcode::BIT_LEFT:
            result <<= 1;
            break;
        case Opcode::BIT_RIGHT:
            result >>= 1;
            break;
        case Opcode::RET:
            std::cout << result;
            return 0;
        }
    }
}

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

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

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

Вычисленные Gotos

Вычисляемые gotos - это конструкция, хорошо известная программистам на Фортране и тем, кто использует определенное (нестандартное) расширение GCC. Я не поддерживаю использование какого-либо нестандартного, определяемого реализацией и (очевидно) неопределенного поведения. Однако, чтобы проиллюстрировать рассматриваемую концепцию, я буду использовать синтаксис упомянутого расширения GCC.

В стандартном C ++ нам разрешено определять метки, к которым впоследствии можно будет перейти с помощью оператора goto:

goto some_label;

some_label:
    do_something();

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

Использование оператора goto может быть быстрее, чем вызов функции. Это потому, что объем бумажной работы, такой как настройка стека и возврат значения, который должен быть выполнен при вызове функции. Между тем, goto иногда можно преобразовать в одну jmp инструкцию сборки.

Чтобы использовать весь потенциал goto, было сделано расширение компилятора GCC, которое позволяет goto быть более динамичным. То есть метку, к которой нужно перейти, можно определить во время выполнения.

Это расширение позволяет получить указатель метки, аналогичный указателю на функцию, и goto к нему:

    void* label_ptr = &&some_label;
    goto (*label_ptr);

some_label:
    do_something();

Это интересная концепция, которая позволяет нам еще больше улучшить нашу простую виртуальную машину. Вместо использования оператора switch мы будем использовать массив указателей меток (так называемую таблицу переходов), а затем goto на соответствующий (код операции будет использоваться для индексации массива):

// [Courtesy of Eli Bendersky][4]
// This code is licensed with the [Unlicense][5]

int interp_cgoto(unsigned char* code, int initval) {
    /* The indices of labels in the dispatch_table are the relevant opcodes
    */
    static void* dispatch_table[] = {
        &&do_halt, &&do_inc, &&do_dec, &&do_mul2,
        &&do_div2, &&do_add7, &&do_neg};
    #define DISPATCH() goto *dispatch_table[code[pc++]]

    int pc = 0;
    int val = initval;

    DISPATCH();
    while (1) {
        do_halt:
            return val;
        do_inc:
            val++;
            DISPATCH();
        do_dec:
            val--;
            DISPATCH();
        do_mul2:
            val *= 2;
            DISPATCH();
        do_div2:
            val /= 2;
            DISPATCH();
        do_add7:
            val += 7;
            DISPATCH();
        do_neg:
            val = -val;
            DISPATCH();
    }
}

Эта версия примерно на 25% быстрее, чем та, в которой используется switch (тот, что указан в сообщении блога по ссылке, а не тот, что выше). Это связано с тем, что после каждой операции выполняется только один переход вместо двух.

Поток управления с switch:  2 перехода с переключателем Например, если бы мы хотели выполнить Opcode::FOO, а затем Opcode::SOMETHING, это выглядело бы так:  введите описание изображения здесь Как видите, после выполнения инструкции выполняется два перехода. Первый - это возврат к коду switch, а второй - к фактической инструкции.

Напротив, если бы мы использовали массив указателей меток (напомним, они нестандартные), у нас был бы только один переход:  введите описание изображения здесь

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

Теперь мы знаем, что, используя массив указателей меток вместо switch, мы можем значительно улучшить производительность нашей виртуальной машины (примерно на 20%). Я подумал, что, возможно, для этого могут быть и другие приложения.

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

std::vector<Base*> objects;
objects = get_objects();
for (auto object : objects)
{
    object->foo();
}

Теперь у этого гораздо больше приложений.

Однако есть одна проблема: в стандартном C ++ нет ничего, кроме указателей меток. Таким образом, возникает вопрос: есть ли способ имитировать поведение вычисленных goto в стандартном C ++ , которое могло бы соответствовать им по производительности?.

Изменить 1:

Есть еще одна обратная сторона использования переключателя. Мне об этом напомнил user1937198. Это обязательная проверка. Короче говоря, он проверяет, соответствует ли значение переменной внутри switch любому из case. Он добавляет избыточное ветвление (эта проверка предусмотрена стандартом).

Изменить 2:

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

  1. Таблицы переходов отсутствуют - стандартный C ++
  2. При добавлении нового производного класса потребуется изменить все таблицы переходов.

Я был бы благодарен, если бы кто-то придумал какой-то тип магии шаблонов (или макрос в крайнем случае), который позволил бы написать его, чтобы он был более чистым, расширяемым и автоматизированным, например:


person Community    schedule 08.11.2019    source источник
comment
AFAIK вычисленные goto - это путь. Возможность использования только с GCC не является препятствием для кодовой базы.   -  person NathanOliver    schedule 09.11.2019
comment
@ NathanOliver-ReinstateMonica Хотя GCC действительно поддерживает многие платформы, мне стало неудобно использовать нестандартное поведение, поскольку оно теоретически может измениться в любой момент.   -  person    schedule 09.11.2019
comment
Я помню, как кто-то сказал мне, что switches реализованы в терминах goto внизу, поэтому для меня это не имеет смысла. Но я не могу это проверить. И это единственное, что я могу дать этому разговору.   -  person    schedule 09.11.2019
comment
@Chipster Вы правы. switches по сути gotos. Дело в том, что на самом деле переключатель состоит из двух goto, как вы можете видеть на схеме.   -  person    schedule 09.11.2019
comment
Какой компилятор и уровень оптимизации вы тестируете? clang ++ 9.0 компилирует ваш пример переключения в таблицу переходов с дополнительной проверкой на предмет отсутствия ответвлений, без проверки, если вы добавляете значение по умолчанию со встроенным недоступным: gcc.godbolt.org/z/ywDqmK   -  person user1937198    schedule 09.11.2019
comment
@ user1937198 Дело в том, что я ограничен использованием MSVC ... Тем не менее, спасибо за упоминание проверки границ - я хотел упомянуть об этом, но забыл.   -  person    schedule 09.11.2019
comment
Я просто жду, пока мастер шаблонов найдет решение для этого ;-) Честно говоря, основная проблема с вычисляемым goto заключается в том, что ввод обычно не является корректным: виртуальная машина, определенная для программной эмуляции, обычно используется в 256 различных OP-кодов, строго ограничивающих размер таблицы диспетчеризации. Но общая диспетчеризация, как это делается с v-таблицами в C ++, не обеспечивает такой роскоши. V-таблицы (= идентификаторы классов) могут находиться практически в любом месте памяти, поэтому вы не можете создать для них таблицу распределения. Тем не менее, v-таблица - это форма вычисляемого goto (+ накладные расходы на вызов функции).   -  person cmaster - reinstate monica    schedule 09.11.2019
comment
@cmaster Дело в том, что накладные расходы на вызов функции могут быть полностью опущены, если вместо вызова функций (и последующего возврата из них) мы будем переходить от одной к следующей. Уточню в вопросе, спасибо.   -  person    schedule 09.11.2019
comment
Между прочим, в сборке этот трюк имеет версию без таблицы, фактически вычисляя адрес, а не ища его (требует некоторого заполнения).   -  person harold    schedule 09.11.2019
comment
@harold Звучит очень интересно. Не могли бы вы разместить ссылку на этот метод?   -  person    schedule 09.11.2019
comment
@YanB. в этом вопросе использовалась специальная версия, я не смог найти хорошую ссылку, но это известный метод сборки фольклор, я полагаю? Также вам может понравиться это   -  person harold    schedule 09.11.2019
comment
@harold Большое спасибо за ссылку на это. Я обязательно поищу дополнительную информацию по этой теме, это выглядит интересно.   -  person    schedule 09.11.2019


Ответы (1)


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

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

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

Решение первой проблемы простое. Не используйте диапазон C ++ для цикла, используйте цикл while или цикл без условий. Решение для второго, к сожалению, требует кода платформы, чтобы сообщить оптимизатору, что по умолчанию используется неопределенное поведение в форме _assume(0), но что-то аналогичное присутствует в большинстве компиляторов (__builtin_unreachable() в clang и gcc), и может быть условно скомпилировано ни с чем, если нет эквивалент присутствует без каких-либо проблем с правильностью.

Итак, результат:

#include <iostream>

enum class Opcode
{
    HALT,
    INC,
    DEC,
    BIT_LEFT,
    BIT_RIGHT,
    RET
};

int run(Opcode* program) {
    int result = 0;
    for (int i = 0; true;i++)
    {
        auto instruction = program[i];
        switch (instruction)
        {
        case Opcode::HALT:
            break;
        case Opcode::INC:
            ++result;
            break;
        case Opcode::DEC:
            --result;
            break;
        case Opcode::BIT_LEFT:
            result <<= 1;
            break;
        case Opcode::BIT_RIGHT:
            result >>= 1;
            break;
        case Opcode::RET:
            std::cout << result;
            return 0;
        default:
            __assume(0);
        }
    }
}

Созданную сборку можно проверить на godbolt

person user1937198    schedule 08.11.2019
comment
@ Jarod42 Просто копирование имен в исходном коде, и это кажется немного ортогональным к проблеме таблиц переходов. - person user1937198; 09.11.2019
comment
Спасибо за Ваш ответ. После первоначального тестирования производительность, похоже, улучшилась до точки с погрешностью по сравнению с вычисленным goto. Я был бы признателен, если бы вы показали решение общей проблемы вызова виртуальных функций в цикле (переключатель является частным случаем этой проблемы - каждый код операции может быть объектом с виртуальной функцией, унаследованной от общего базового абстрактного класса). - person ; 09.11.2019
comment
Кстати, NOOP действительно было бы лучшим названием для HALT. ;) - person ; 09.11.2019
comment
@YanB. Обобщенный случай намного сложнее. Тот факт, что вызовы виртуальных функций являются открытым набором, а не закрытым набором переключателя, означает, что оптимизатор не может построить таблицу переходов. - person user1937198; 09.11.2019
comment
@ user1937198 Оптимизатор не может сделать это сам по себе, но можно было бы что-то сделать (может быть, как-то пометить виртуальные функции с помощью какого-либо макроса?), чтобы позволить ему построить такую ​​таблицу. - person ; 09.11.2019
comment
Добавьте LTO в сборку с соответствующими флагами, и оптимизатор сможет вычислить все виртуальные объекты самостоятельно и улучшить ситуацию. - person Michael Dorgan; 12.11.2019
comment
Я знал это! Мне просто было интересно, есть ли более переносимый способ сделать это (перенос виртуальной машины с C на C ++), и это действительно более переносимый способ - он работает одинаково во всех основных компиляторах. Фактически, вы даже можете использовать std :: abort или любую другую функцию [[noreturn]] (с новыми стандартами C ++) для получения того же результата - хотя это добавило бы одну проверку привязки в начале переключателя перед отправкой метки - person Andrian Nord; 22.06.2021