Подтвердить что ты не робот

Внутреннее слияние без ветвей медленнее, чем внутреннее слияние с ветвью

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

template<
    typename InputIterator1,
    typename InputIterator2,
    typename OutputIterator,
    typename Compare = std::less<>
>
auto half_inplace_merge(InputIterator1 first1, InputIterator1 last1,
                        InputIterator2 first2, InputIterator2 last2,
                        OutputIterator result, Compare compare={})
    -> void
{
    for (; first1 != last1; ++result) {
        if (first2 == last2) {
            std::swap_ranges(first1, last1, result);
            return;
        }

        if (compare(*first2, *first1)) {
            std::iter_swap(result, first2);
            ++first2;
        } else {
            std::iter_swap(result, first1);
            ++first1;
        }
    }
    // first2 through last2 are already in the right spot
}

Эта функция была адаптирована из функции eponym в реализации libС++ std::inplace_merge; эта новая версия свопит элементы с другой частью исходного массива вместо перемещения элементов из вспомогательного массива.

Поскольку слияние является внутренним, я понял, что на самом деле мне не нужно иметь два разных типа ввода: InputIterator1 и InputIterator2 всегда одинаковы. Затем я понял, что, поскольку операции с first1 и first2 всегда были одинаковыми, я мог хранить их в двухэлементном массиве и использовать результат сравнения, чтобы индексировать массив, чтобы узнать, какой итератор должен меняться и увеличивать. С помощью этого небольшого трюка я избавляюсь от ветки и получаю алгоритм с наибольшим разнесением:

template<
    typename InputIterator,
    typename OutputIterator,
    typename Compare = std::less<>
>
auto half_inplace_merge(InputIterator first1, InputIterator last1,
                        InputIterator first2, InputIterator last2,
                        OutputIterator result, Compare compare={})
    -> void
{
    InputIterator store[] = { first1, first2 };

    for (; store[0] != last1; ++result) {
        if (store[1] == last2) {
            std::swap_ranges(store[0], last1, result);
            return;
        }

        bool cmp = compare(*store[1], *store[0]);
        std::iter_swap(result, store[cmp]);
        ++store[cmp];
    }
    // first2 through last2 are already in the right spot
}

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

Итак, кто-нибудь может объяснить, почему нерасширяющийся код медленнее?


Приложение: для тех, кто хочет запустить те же тесты, что и я... ну, это будет немного сложно: я использовал тесты из личной библиотеки, которые включают много вещей; вам необходимо загрузить в библиотеку, добавить этот файл и запустить этот тест после добавления нужной строки для вызова quick_merge_sort рядом с выделенной секцией (вам нужно будет перенаправить стандартный вывод программы в файл в подкаталоге profiles). Затем вам нужно запустить этот Python script, чтобы увидеть результаты, добавив quick_merge_sort к выделенной строке. Обратите внимание, что необходимо установить NumPy и matplotlib.

4b9b3361

Ответ 1

Такое большое различие является произведением двух условий.

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

** Специфика компиляции во всем этом ответе использовала g++ 6.2.1 20160916, пакет Fedora 24 dnf по умолчанию, а также ядро ​​LINUX 4.8.8-200.fc24.x86_64. Runtime был кеш Intel i7-2600 8M. Также для Atmel SAM3X8E ARM Cortex-M3 с arm-none-eabi-g++ 4.8.3-2014q1.

второе условие связано с компиляцией второго трюка, описанного в пункте 3 предложения 2 вопроса. Первый трюк, сокращение типов в шаблоне, не вызвал существенных изменений в языке ассемблера. Второй трюк вызвал разницу уровня сборки на выходе на выходе из компилятора для двух вызовов.

Этот прекомпилятор взлома может облегчить тестирование.

#ifdef ORIG
#define half_inplace_merge half_inplace_merge_orig
#else // ORIG
#define half_inplace_merge half_inplace_merge_slow
#endif // ORIG
...
half_inplace_merge(niInA.begin(), niInA.end(),
        niInB.begin(), niInB.end(),
        niOut.begin(), compare);

Выполнение и сравнение с помощью этих команд в оболочке bash использует взлом прекомпилятора.

g++ -DORIG -S -fverbose-asm -o /tmp/qq.orig.s /tmp/qq.cpp
g++ -DSLOW -S -fverbose-asm -o /tmp/qq.slow.s /tmp/qq.cpp
araxis.sh /tmp/qq.orig.s /tmp/qq.slow.s  # to run Araxis Merge in Wine

Эти инструкции являются результатом инициализации хранилища InputIterator [], но это вне цикла.

leaq    -48(%rbp), %rax #, _4
movq    -64(%rbp), %rdx # first1, tmp104
movq    %rdx, (%rax)    # tmp104, *_5
leaq    8(%rax), %rdx   #, _9
movq    -96(%rbp), %rax # first2, tmp105
movq    %rax, (%rdx)    # tmp105, *_9

Первичное замедление происходит при разыменовании двух элементов, содержащихся в хранилище [], при необходимости с помощью сравнения и свопинга, и это находится в цикле. Эти инструкции не существуют в версии без второго трюка.

movb    %al, -17(%rbp)  # _27, cmp
movzbl  -17(%rbp), %eax # cmp, _29
cltq
...
movzbl  -17(%rbp), %edx # cmp, _31
leaq    -48(%rbp), %rax #, tmp121
movslq  %edx, %rdx  # _31, tmp122
salq    $3, %rdx    #, tmp123
addq    %rdx, %rax  # tmp123, _32

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

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

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

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

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

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

if (compare(*first2, *first1)) {
    std::iter_swap(result, first2 ++);
} else {
    std::iter_swap(result, first1 ++);
}

Ответ 2

Ниже приводится простое интуитивное объяснение:

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

В безвкусном коде мы не можем легко сделать это из-за store[cmp] и ++store[cmp] - и это подразумевает накладные расходы при использовании store[0] и store[1].

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