Как ускорить динамическую диспетчеризацию на 20%, используя вычисленные gotos в стандарте C ++ - PullRequest
4 голосов
/ 09 ноября 2019

Прежде чем вы проголосуете против или начнете говорить, что goto злое и устарело, пожалуйста, прочитайте обоснование того, почему оно жизнеспособно в этом случае. Прежде чем пометить его как дубликат, прочитайте полный вопрос.

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

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

#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).

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

Computed Gotos

Computed gotos - конструкция, хорошо известная Fortran. программисты и те, кто использует определенное (нестандартное) расширение 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 jumps with switch Например, если мы хотим выполнить Opcode::FOOа затем Opcode::SOMETHING, это будет выглядеть так: enter image description here Как вы можете видеть, после выполнения инструкции выполняется два перехода. Первый возвращается к коду switch, а второй - к действительной инструкции.

Напротив, если бы мы использовали массив указателей меток (как напоминание, они нестандартны), у нас был бы только один скачок: enter image description here

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

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

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

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

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

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

Edit 1:

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

Редактировать 2:

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

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

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

1 Ответ

3 голосов
/ 09 ноября 2019

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

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

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

Решение первой проблемы простое. Не используйте диапазон c ++ для цикла, используйте цикл while или цикл for без условий. Решение для второго, к сожалению, требует специфичного для платформы кода, чтобы сообщить оптимизатору, что по умолчанию задано неопределенное поведение в форме _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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...