Для разницы в производительности цикла и оптимизации компилятора


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


Ответ Джерри Коффина объясняет, что происходит при установке флажков оптимизации для этого примера. Остается без ответа, почему superCalculationA работает медленнее, чем superCalculationB, когда B выполняет одну дополнительную ссылку на память и одно дополнение для каждой итерации. Сообщение Nemo показывает выход ассемблера. Я подтвердил эту компиляцию с флагом -S на моем ПК, Sandy Bridge 2,9 ГГц (i5-2310), запуском Ubuntu 12.04 64-бит, как это предлагает Matteo Italia.


Я экспериментировал с производительностью for-loops, когда я наткнулся на следующий случай.

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

#include <cstdint>
#include <chrono>
#include <cstdio>

using std::uint64_t;

uint64_t superCalculationA(int init, int end)
{
    uint64_t total = 0;
    for (int i = init; i < end; i++)
        total += i;
    return total;
}

uint64_t superCalculationB(int init, int todo)
{
    uint64_t total = 0;
    for (int i = init; i < init + todo; i++)
        total += i;
    return total;
}

int main()
{
    const uint64_t answer = 500000110500000000;

    std::chrono::time_point<std::chrono::high_resolution_clock> start, end;
    double elapsed;

    std::printf("=====================================================\n");

    start = std::chrono::high_resolution_clock::now();
    uint64_t ret1 = superCalculationA(111, 1000000111);
    end = std::chrono::high_resolution_clock::now();
    elapsed = (end - start).count() * ((double) std::chrono::high_resolution_clock::period::num / std::chrono::high_resolution_clock::period::den);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    start = std::chrono::high_resolution_clock::now();
    uint64_t ret2 = superCalculationB(111, 1000000000);
    end = std::chrono::high_resolution_clock::now();
    elapsed = (end - start).count() * ((double) std::chrono::high_resolution_clock::period::num / std::chrono::high_resolution_clock::period::den);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    if (ret1 == answer)
    {
        std::printf("The first method, i.e. superCalculationA, succeeded.\n");
    }
    if (ret2 == answer)
    {
        std::printf("The second method, i.e. superCalculationB, succeeded.\n");
    }

    std::printf("=====================================================\n");

    return 0;
}

Компиляция этого кода с помощью

g++ main.cpp -o output --std = С++ 11

приводит к следующему результату:

=====================================================
Elapsed time: 2.859 s | 2859.441 ms | 2859440.968 us
Elapsed time: 2.204 s | 2204.059 ms | 2204059.262 us
The first method, i.e. superCalculationA, succeeded.
The second method, i.e. superCalculationB, succeeded.
=====================================================

Мой первый вопрос: почему второй цикл работает на 23% быстрее, чем первый?

С другой стороны, если я скомпилирую код с помощью

g++ main.cpp -o output --std = С++ 11 -O1

Результаты значительно улучшаются,

=====================================================
Elapsed time: 0.318 s | 317.773 ms | 317773.142 us
Elapsed time: 0.314 s | 314.429 ms | 314429.393 us
The first method, i.e. superCalculationA, succeeded.
The second method, i.e. superCalculationB, succeeded.
=====================================================

и разница во времени почти исчезает.

Но я не мог поверить своим глазам, когда я установил флаг -O2,

g++ main.cpp -o output --std = С++ 11 -O2

и получил следующее:

=====================================================
Elapsed time: 0.000 s | 0.000 ms | 0.328 us
Elapsed time: 0.000 s | 0.000 ms | 0.208 us
The first method, i.e. superCalculationA, succeeded.
The second method, i.e. superCalculationB, succeeded.
=====================================================

Итак, мой второй вопрос: Что делает компилятор, когда я устанавливаю флаги -O1 и -O2, что приводит к этому гигантскому повышению производительности?

Я проверил Оптимизированный вариант - с использованием коллекции компиляторов GNU (GCC), но это не разъясняло.


Кстати, я компилирую этот код с g++ (GCC) 4.9.1.


РЕДАКТИРОВАТЬ, чтобы подтвердить предположение Базиле Старинкевича

Я редактировал код, теперь main выглядит так:

int main(int argc, char **argv)
{
    int start = atoi(argv[1]);
    int end   = atoi(argv[2]);
    int delta = end - start + 1;

    std::chrono::time_point<std::chrono::high_resolution_clock> t_start, t_end;
    double elapsed;

    std::printf("=====================================================\n");

    t_start = std::chrono::high_resolution_clock::now();
    uint64_t ret1 = superCalculationB(start, delta);
    t_end = std::chrono::high_resolution_clock::now();
    elapsed = (t_end - t_start).count() * ((double) std::chrono::high_resolution_clock::period::num / std::chrono::high_resolution_clock::period::den);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    t_start = std::chrono::high_resolution_clock::now();
    uint64_t ret2 = superCalculationA(start, end);
    t_end = std::chrono::high_resolution_clock::now();
    elapsed = (t_end - t_start).count() * ((double) std::chrono::high_resolution_clock::period::num / std::chrono::high_resolution_clock::period::den);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    std::printf("Results were %s\n", (ret1 == ret2) ? "the same!" : "different!");
    std::printf("=====================================================\n");

    return 0;
}

Эти изменения действительно увеличили время вычисления, как для -O1, так и -O2. Оба дают мне около 620 мс. Что доказывает, что -O2 действительно выполнял некоторые вычисления во время компиляции.

Я все еще не понимаю, что делают эти флаги для повышения производительности, а -Ofast делает еще лучше, примерно на 320 мс.

Также обратите внимание, что я изменил порядок, в котором функции A и B вызываются для проверки предположения Джерри Коффина. Компиляция этого кода без флагов оптимизатора по-прежнему дает мне около 2.2 секунд в B и 2.8 секунды в A. Поэтому я полагаю, что это не кеш. Просто подтверждая, что я не говорю об оптимизации в первом случае (тот, у которого нет флагов), я просто хочу знать, что заставляет цикл секунд работать быстрее первого.

Ответ 1

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


Похоже, что разница в производительности в случае -O0 связана с конвейерной обработкой процессора.

Сначала сборка (для сборки -O0), скопированная из ответа Nemo, с некоторыми моими собственными комментариями:

superCalculationA(int, int):
    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -20(%rbp)    # init
    movl    %esi, -24(%rbp)    # end
    movq    $0, -8(%rbp)       # total = 0
    movl    -20(%rbp), %eax    # copy init to register rax
    movl    %eax, -12(%rbp)    # i = [rax]
    jmp .L7
.L8:
    movl    -12(%rbp), %eax    # copy i to register rax
    cltq
    addq    %rax, -8(%rbp)     # total += [rax]
    addl    $1, -12(%rbp)      # i++
.L7:
    movl    -12(%rbp), %eax    # copy i to register rax
    cmpl    -24(%rbp), %eax    # [rax] < end
    jl  .L8
    movq    -8(%rbp), %rax
    popq    %rbp
    ret

superCalculationB(int, int):
    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -20(%rbp)    # init
    movl    %esi, -24(%rbp)    # todo
    movq    $0, -8(%rbp)       # total = 0
    movl    -20(%rbp), %eax    # copy init to register rax
    movl    %eax, -12(%rbp)    # i = [rax]
    jmp .L11
.L12:
    movl    -12(%rbp), %eax    # copy i to register rax
    cltq
    addq    %rax, -8(%rbp)     # total += [rax]
    addl    $1, -12(%rbp)      # i++
.L11:
    movl    -20(%rbp), %edx    # copy init to register rdx
    movl    -24(%rbp), %eax    # copy todo to register rax
    addl    %edx, %eax         # [rax] += [rdx]  (so [rax] = init+todo)
    cmpl    -12(%rbp), %eax    # i < [rax]
    jg  .L12
    movq    -8(%rbp), %rax
    popq    %rbp
    ret

В обеих функциях расположение стека выглядит следующим образом:

Addr Content

24   end/todo
20   init
16   <empty>
12   i
08   total
04   
00   <base pointer>

(Обратите внимание, что total является 64-битным int и поэтому занимает два 4-байтовых слота.)

Это ключевые строки superCalculationA():

    addl    $1, -12(%rbp)      # i++
.L7:
    movl    -12(%rbp), %eax    # copy i to register rax
    cmpl    -24(%rbp), %eax    # [rax] < end

Адрес стека -12(%rbp) (который содержит значение i) записывается в инструкцию addl, а затем сразу же считывается в самой следующей инструкции. Инструкция чтения не может начинаться до завершения записи. Это представляет собой блок в конвейере, заставляя superCalculationA() быть медленнее, чем superCalculationB().

Вам может быть интересно, почему superCalculationB() не имеет этого же блока конвейера. Это действительно просто артефакт о том, как gcc компилирует код в -O0 и не представляет ничего принципиально интересного. В принципе, в superCalculationA() сравнение i<end выполняется путем чтения i из register, а в superCalculationB() сравнение i<init+todo выполняется путем чтения i из стек.

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

for (int i = init; i < end; i++)

с

for (int i = init; end > i; i++)

в superCalculateA(). Сгенерированная сборка тогда выглядит одинаково, и только следующее изменение к ключевым строкам:

    addl    $1, -12(%rbp)      # i++
.L7:
    movl    -24(%rbp), %eax    # copy end to register rax
    cmpl    -12(%rbp), %eax    # i < [rax]

Теперь i считывается из стека, а блок конвейера отсутствует. Ниже приведены номера производительности после внесения такого изменения:

=====================================================
Elapsed time: 2.296 s | 2295.812 ms | 2295812.000 us
Elapsed time: 2.368 s | 2367.634 ms | 2367634.000 us
The first method, i.e. superCalculationA, succeeded.
The second method, i.e. superCalculationB, succeeded.
=====================================================

Следует отметить, что это действительно игрушечный пример, поскольку мы компилируем с -O0. В реальном мире мы компилируем с -O2 или -O3. В этом случае компилятор заказывает инструкции таким образом, чтобы минимизировать блоки трубопроводов, и нам не нужно беспокоиться о том, писать ли i<end или end>i.

Ответ 2

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

Чтобы проверить теорию, я переделал ваш код, чтобы изменить порядок, в котором были вызваны два вычисления:

#include <cstdint>
#include <chrono>
#include <cstdio>

using std::uint64_t;

uint64_t superCalculationA(int init, int end)
{
    uint64_t total = 0;
    for (int i = init; i < end; i++)
        total += i;
    return total;
}

uint64_t superCalculationB(int init, int todo)
{
    uint64_t total = 0;
    for (int i = init; i < init + todo; i++)
        total += i;
    return total;
}

int main()
{
    const uint64_t answer = 500000110500000000;

    std::chrono::time_point<std::chrono::high_resolution_clock> start, end;
    double elapsed;

    std::printf("=====================================================\n");

    start = std::chrono::high_resolution_clock::now();
    uint64_t ret2 = superCalculationB(111, 1000000000);
    end = std::chrono::high_resolution_clock::now();
    elapsed = (end - start).count() * ((double) std::chrono::high_resolution_clock::period::num / std::chrono::high_resolution_clock::period::den);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    start = std::chrono::high_resolution_clock::now();
    uint64_t ret1 = superCalculationA(111, 1000000111);
    end = std::chrono::high_resolution_clock::now();
    elapsed = (end - start).count() * ((double) std::chrono::high_resolution_clock::period::num / std::chrono::high_resolution_clock::period::den);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    if (ret1 == answer)
    {
        std::printf("The first method, i.e. superCalculationA, succeeded.\n");
    }
    if (ret2 == answer)
    {
        std::printf("The second method, i.e. superCalculationB, succeeded.\n");
    }

    std::printf("=====================================================\n");

    return 0;
}

В результате я получил:

=====================================================
Elapsed time: 0.286 s | 286.000 ms | 286000.000 us
Elapsed time: 0.271 s | 271.000 ms | 271000.000 us
The first method, i.e. superCalculationA, succeeded.
The second method, i.e. superCalculationB, succeeded.
=====================================================

Итак, когда версия A работает сначала, она медленнее. Когда сначала запускается версия B, она медленнее.

Чтобы подтвердить, я добавил дополнительный вызов superCalculationB, прежде чем делать выбор времени для любой версии A или B. После этого я попытался запустить программу три раза. Для этих трех прогонов я сужу результаты за галстук (версия А была быстрее, а версия Б была быстрей дважды, но ни один из них не выиграл надежно или не был достаточно значительным, чтобы быть значимым).

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

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

uint64_t superCalculationC(int init, int end)
{
    int f_end = end - ((end - init) & 7);

    int i;
    uint64_t total = 0;
    for (i = init; i < f_end; i += 8) {
        total += i;
        total += i + 1;
        total += i + 2;
        total += i + 3;
        total += i + 4;
        total += i + 5;
        total += i + 6;
        total += i + 7;
    }

    for (; i < end; i++)
        total += i;

    return total;
}

Это свойство, которое некоторые могут найти довольно нечетным: оно на самом деле быстрее, если скомпилировано с -O2, чем с -O3. При компиляции с -O2 он также примерно в пять раз быстрее, чем любой из двух других, когда скомпилирован с -O3.

Основная причина увеличения скорости ~ 5x по сравнению с разворачиванием цикла компилятора - это то, что мы развернули цикл несколько иначе (и более разумно, IMO), чем компилятор. Мы вычисляем f_end, чтобы сообщить нам, сколько раз должен выполняться развернутый цикл. Мы выполняем эти итерации, затем выполняем отдельный цикл для "очистки" любых нечетных итераций в конце.

Вместо этого компилятор генерирует код, который примерно эквивалентен примерно так:

for (i = init; i < end; i += 8) {
    total += i;
    if (i + 1 >= end) break;
    total += i + 1;
    if (i + 2 >= end) break;
    total += i + 2;
    // ...
}

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

Учитывая, что такое тривиальное тело цикла выполняется таким большим количеством раз, вы также можете улучшить скорость (при компиляции с -O2) еще больше, развернув больше итераций цикла. С 16 итерациями, развернутыми, он был примерно в два раза быстрее, чем предыдущий код с развернутыми 8 итерациями:

uint64_t superCalculationC(int init, int end)
{
    int first_end = end - ((end - init) & 0xf);

    int i;
    uint64_t total = 0;
    for (i = init; i < first_end; i += 16) {
        total += i + 0;
        total += i + 1;
        total += i + 2;

        // code for `i+3` through `i+13` goes here

        total += i + 14;
        total += i + 15;
    }

    for (; i < end; i++)
        total += i;

    return total;
}

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

Сводка: при -O3 компилятор разворачивает несколько итераций цикла. Это чрезвычайно эффективно в этом случае, прежде всего потому, что у нас много исполнений почти самого тривиального возможного тела цикла. Развертывание цикла вручную еще более эффективно, чем позволить компилятору сделать это - мы можем развернуть более разумно, и мы можем просто развернуть больше итераций, чем компилятор. Дополнительный интеллект может дать нам улучшение около 5: 1, а дополнительные итерации - еще 4: 1 или около того 1 (за счет более длинного, чуть менее читаемого кода).

Заключительное предупреждение: как всегда с оптимизацией, ваш пробег может отличаться. Различия в компиляторах и/или процессорах означают, что вы, вероятно, получите хотя бы несколько иные результаты, чем я. Я ожидал бы, что моя ручная развертка будет значительно быстрее, чем две другие в большинстве случаев, но точно, насколько быстрее будет изменяться.


1. Но учтите, что это сравнение ручного развертки с -O2 с исходным циклом с -O3. Когда скомпилировано с -O3, ручная развертка работает намного медленнее.

Ответ 3

Проверка вывода сборки - единственный способ осветить такие вещи.

Оптимизация компилятора будет делать много вещей, включая вещи, которые не являются строго "стандартными" (хотя это не относится к -O1 и -O2, насколько мне известно) - например check, -Ofast.

Я нашел это полезным: http://gcc.godbolt.org/ и с вашим демо-кодом здесь

Ответ 4

-O2

Объяснение результата -O2 легко, глядя на код godbolt изменить на -O2

main:
pushq   %rbx
movl    $.LC2, %edi
call    puts
call    std::chrono::_V2::system_clock::now()
movq    %rax, %rbx
call    std::chrono::_V2::system_clock::now()
pxor    %xmm0, %xmm0
subq    %rbx, %rax
movsd   .LC4(%rip), %xmm2
movl    $.LC6, %edi
movsd   .LC5(%rip), %xmm1
cvtsi2sdq   %rax, %xmm0
movl    $3, %eax
mulsd   .LC3(%rip), %xmm0
mulsd   %xmm0, %xmm2
mulsd   %xmm0, %xmm1
call    printf
call    std::chrono::_V2::system_clock::now()
movq    %rax, %rbx
call    std::chrono::_V2::system_clock::now()
pxor    %xmm0, %xmm0
subq    %rbx, %rax
movsd   .LC4(%rip), %xmm2
movl    $.LC6, %edi
movsd   .LC5(%rip), %xmm1
cvtsi2sdq   %rax, %xmm0
movl    $3, %eax
mulsd   .LC3(%rip), %xmm0
mulsd   %xmm0, %xmm2
mulsd   %xmm0, %xmm1
call    printf
movl    $.LC7, %edi
call    puts
movl    $.LC8, %edi
call    puts
movl    $.LC2, %edi
call    puts
xorl    %eax, %eax
popq    %rbx
ret

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

Теперь, почему это может быть? его, конечно, сила оптимизации, программа слишком проста...

Сначала применяется сила inlining, после чего компилятор может видеть, что все параметры на самом деле являются буквальными значениями (111, 1000000111, 1000000000, 500000110500000000) и, следовательно, константами.

Обнаружено, что init + todo является инвариантом цикла и заменяет его концом, определяя конец перед циклом из B как end = init + todo = 111 + 1000000000 = 1000000111

Теперь обе петли содержат только значения времени компиляции. Они также совершенно одинаковы:

uint64_t total = 0;
for (int i = 111; i < 1000000111; i++)
    total += i;
return total;

Компилятор видит, что это суммирование, total - это сумматор, равный шаг 1, поэтому компилятор делает разворот конечной петли, а именно все, но он знает, что эта форма имеет сумму

Переписывание Gauss formel s = n * (n + 1)

111+1000000110
110+1000000109
...
1000000109+110
1000000110+111=1000000221

loops = 1000000111-111 = 1E9

наполовину, так как мы получили двойную ссылку на

1000000221 * 1E9/2 = 500000110500000000

который является результатом поиска 500000110500000000

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

Указанное время - это минимальное время для system_clock на вашем ПК.

-O0

Сроки выполнения -O0 сложнее и, скорее всего, являются артефактом отсутствующего выравнивания для функций и прыжков, и микросхему μops, и loopbuffer любят выравнивание 32 байтов. Вы можете проверить это, если вы добавите некоторые

asm("nop");

перед петлей A, 2-3 может сделать трюк. Storeforwards также так, что их значения естественно выровнены.

Ответ 5

(Это не совсем ответ, но он содержит больше данных, в том числе некоторые, которые конфликтуют с Джерри Коффином.)

Интересный вопрос в том, почему неоптимизированные подпрограммы выполняют так по-разному и контр-интуитивно. Случаи -O2 и -O3 относительно просты для объяснения, а другие сделали это.

Для полноты вот сборка (спасибо @Rutan Kax) за superCalculationA и superCalculationB от GCC 4.9.1:

superCalculationA(int, int):
    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -20(%rbp)
    movl    %esi, -24(%rbp)
    movq    $0, -8(%rbp)
    movl    -20(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp .L7
.L8:
    movl    -12(%rbp), %eax
    cltq
    addq    %rax, -8(%rbp)
    addl    $1, -12(%rbp)
.L7:
    movl    -12(%rbp), %eax
    cmpl    -24(%rbp), %eax
    jl  .L8
    movq    -8(%rbp), %rax
    popq    %rbp
    ret

superCalculationB(int, int):
    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -20(%rbp)
    movl    %esi, -24(%rbp)
    movq    $0, -8(%rbp)
    movl    -20(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp .L11
.L12:
    movl    -12(%rbp), %eax
    cltq
    addq    %rax, -8(%rbp)
    addl    $1, -12(%rbp)
.L11:
    movl    -20(%rbp), %edx
    movl    -24(%rbp), %eax
    addl    %edx, %eax
    cmpl    -12(%rbp), %eax
    jg  .L12
    movq    -8(%rbp), %rax
    popq    %rbp
    ret

Конечно, мне кажется, что B делает больше работы.

Моя тестовая платформа - это процессор EPG Sandy Bridge с частотой 2,9 ГГц (E5-2690), работающий с Red Hat Enterprise 6 Update 3. Мой компилятор GCC 4.9.1 и создает сборку выше.

Чтобы убедиться, что Turbo Boost и связанные с ним технологии частотной обработки не мешают измерению, я побежал:

pkill cpuspeed # if you have it running
grep MHz /proc/cpuinfo # to see where you start
modprobe acpi_cpufreq # if you do not have it loaded
cd /sys/devices/system/cpu 
for cpuN in cpu[0-9]* ; do
    echo userspace > $cpuN/cpufreq/scaling_governor
    echo 2000000 > $cpuN/cpufreq/scaling_setspeed
done
grep MHz /proc/cpuinfo # to see if it worked

Это приводит к тому, что частота процессора достигает 2,0 ГГц и отключает Turbo Boost.

Джерри заметил, что эти две процедуры выполняются быстрее или медленнее в зависимости от того, в каком порядке он выполнял их. Я не смог воспроизвести этот результат. Для меня superCalculationB последовательно работает на 25-30% быстрее, чем superCalculationA, независимо от настроек Turbo Boost или тактовой частоты. Это включает в себя запуск их несколько раз в произвольном порядке. Например, на частоте 2,0 ГГц superCalculationA последовательно занимает чуть более 4500 мс, а superCalculationB последовательно занимает чуть меньше 3600 мс.

Мне еще предстоит увидеть какую-либо теорию, которая даже начнет объяснять это.

Ответ 6

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

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

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

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

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

Ответ 7

Ответ на первый вопрос:

1- Это делается быстрее после однократного выполнения для циклов, но я не уверен, что просто комментирую в соответствии с результатами моего эксперимента (эксперимент 1 изменил их имена (B- > A, A- > B), эксперимент 2 запустил одну функцию имеет для цикла до проверки времени, эксперимент 3 запускает один для цикла до проверки времени)

2- Первые программы должны работать быстрее, причина в том, что вторая функция выполняет 2 операции, когда первая функция выполняет 1 операцию.

Я оставляю здесь обновленный код, который объясняет мой ответ.

Ответ на второй вопрос:

Я не уверен, но могут прийти два пути,

Он может каким-то образом формализовать вашу функцию и избавиться от циклов, потому что разница может быть уничтожен таким образом (например, "return end-init" или "return todo" я dunno, я не уверен)

У него есть -fauto_inc_dec, и он может сделать это различие, потому что эти функции связаны с уровнями и сокращениями.

Надеюсь, это поможет.

#include <cstdint>
#include <ctime>
#include <cstdio>

using std::uint64_t;

uint64_t superCalculationA(int init, int end)
{
    uint64_t total = 0;
    for (int i = init; i < end; i++)
        total += i;
    return total;
}
uint64_t superCalculationB(int init, int todo)
{
    uint64_t total = 0;
    for (int i = init; i < init+todo; i++)
        total += i;
    return total;
}
int add(int a1,int a2){printf("multiple times added\n");return a1+a2;}
uint64_t superCalculationC(int init, int todo)
{
    uint64_t total = 0;
    for (int i = init; i < add(init , todo); i++)
        total += i;
    return total;
}

int main()
{
    const uint64_t answer = 500000110500000000;

    std::clock_t start=clock();
    double elapsed;

    std::printf("=====================================================\n");

    superCalculationA(111, 1000000111);

    start = clock();
    uint64_t ret1 = superCalculationA(111, 1000000111);
    elapsed = ((std::clock()-start)*1.0/CLOCKS_PER_SEC);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed,    1e+6*elapsed);

    start = clock();
    uint64_t ret2 = superCalculationB(111, 1000000000);
    elapsed = ((std::clock()-start)*1.0/CLOCKS_PER_SEC);
    std::printf("Elapsed time: %.3f s | %.3f ms | %.3f us\n", elapsed, 1e+3*elapsed, 1e+6*elapsed);

    if (ret1 == answer)
    {
        std::printf("The first method, i.e. superCalculationA, succeeded.\n");
    }
    if (ret2 == answer)
    {
        std::printf("The second method, i.e. superCalculationB, succeeded.\n");
    }

    std::printf("=====================================================\n");

    return 0;
}