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

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

#include <string>
#include <vector>
#include <iostream>
#include <random>
#include <chrono>
#include <functional>

constexpr double usec_to_sec = 1000000.0;

// Simple convenience timer
class Timer
{
    std::chrono::high_resolution_clock::time_point start_time;
public:
    Timer() : start_time(std::chrono::high_resolution_clock::now()) { }
    int64_t operator()() const {
        return static_cast<int64_t>(
        std::chrono::duration_cast<std::chrono::microseconds>(
            std::chrono::high_resolution_clock::now()-start_time).count()
        );
    }
};

// Convenience random number generator
template <typename T>
class RandGen
{
    mutable std::default_random_engine generator;
    std::uniform_int_distribution<T> distribution;

    constexpr unsigned make_seed() const {
        return static_cast<unsigned>(std::chrono::system_clock::now().time_since_epoch().count());
    }
public:
    RandGen(T min, T max) : generator(make_seed()), distribution(min, max) { }
    T operator ()() { return distribution(generator); }
};

// Printer class
class Printer
{
    std::string filename;
    template <class S>    
    friend Printer &operator<<(Printer &, S &&s);
public:
    Printer(const char *filename) : filename(filename) {}
};

template <class S>
Printer &operator<<(Printer &pm, S &&s) {
    std::cout << s;
    return pm;
}

// +------------+
// | Main Stuff |
// +------------+
void runtest(size_t run_length)
{
    static RandGen<size_t> word_sz_generator(10, 20);
    static RandGen<int> rand_char_generator(0, 25);

    size_t total_char_count = 0;
    std::vector<std::string> word_list;
    word_list.reserve(run_length);

    Printer printer("benchmark.dat");
    printer << "Running test... ";

    Timer timer; // start timer
    for (auto i = 0; i < run_length; i++) {

        size_t word_sz = word_sz_generator();
        std::string word;
        for (auto sz = 0; sz < word_sz; sz++) {
            word.push_back(static_cast<char>(rand_char_generator())+'a');
        }
        word_list.emplace_back(std::move(word));
        total_char_count += word_sz;
    }
    int64_t execution_time_usec = timer(); // stop timer

    printer << /*run_length*/ word_list.size() << " words, and " 
            << total_char_count << " total characters, were built in "
            << execution_time_usec/usec_to_sec << " seconds.n";
}

int main(int argc, char **argv)
{
    constexpr size_t iterations = 30;
    constexpr size_t run_length = 50000000;

    for (auto i = 0; i < iterations; i++)
        runtest(run_length);

    return EXIT_SUCCESS;
}

в 1st класса, Timer, это просто небольшой класс удобства (намеренно не хорошо признакам, для краткости) для синхронизации кода.

я пытался обойтись без 2nd класс RandGen (который просто генерирует случайные значения), но любая попытка исключить из тестового кода, проблема авто-магически исчезают. Я подозреваю, что проблема как-то связана с этим. Но я не могу понять как.

3-хrd класс Printer кажется совершенно ненужным для этого вопроса, но снова, включая его, кажется, усугубляет вопрос.

Итак, теперь мы переходим к main() (который просто запускает тест) и runtest().

runtest() отвратительно, поэтому, пожалуйста, не смотрите на него с точки зрения "чистого кода". Изменение его любым способом (ex. перемещение внутреннего for loop в своей собственной функции) приводит к изменению результатов теста. Самый простой и самый запутанный пример-последняя строка:

printer << /*run_length*/ word_list.size() << " words, and " 
        << total_char_count << " total characters, were built in "
        << execution_time_usec/usec_to_sec << " seconds.n";

в строке выше, run_length и word_list.size() то же самое. Размер vector word_list is определяется run_length. Но, если я запускаю код как есть, я получаю среднее время выполнения 9.8 секунд, тогда как если я раскомментирую run_length и комментарий-out word_list.size(), время выполнения на самом деле увеличение в среднем 10,6 секунд. Я не могу понять, как такое незначительное изменение кода может повлиять на время всей программы в такой степени.

другими словами...

9.8 секунды!--42-->:

printer << /*run_length*/ word_list.size() << " words, and " 
        << total_char_count << " total characters, were built in "
        << execution_time_usec/usec_to_sec << " seconds.n";

10,6 секунд:

printer << run_length /*word_list.size()*/ << " words, and " 
        << total_char_count << " total characters, were built in "
        << execution_time_usec/usec_to_sec << " seconds.n";

я повторил упражнение комментирования и раскомментирования переменных, упомянутых выше, и повторного запуска эталонов, много раз. Контрольные показатели повторяемы и последовательны - т. е. они последовательно составляют 9,8 секунды и 10,6 секунды, соответственно.

вывод кода выглядит следующим образом, для двух случаи:

Running test... 50000000 words, and 750000798 total characters, were built in 9.83379 seconds.
Running test... 50000000 words, and 749978210 total characters, were built in 9.84541 seconds.
Running test... 50000000 words, and 749996688 total characters, were built in 9.87418 seconds.
Running test... 50000000 words, and 749995415 total characters, were built in 9.85704 seconds.
Running test... 50000000 words, and 750017699 total characters, were built in 9.86186 seconds.
Running test... 50000000 words, and 749998680 total characters, were built in 9.83395 seconds.
...

Running test... 50000000 words, and 749988517 total characters, were built in 10.604 seconds.
Running test... 50000000 words, and 749958011 total characters, were built in 10.6283 seconds.
Running test... 50000000 words, and 749994387 total characters, were built in 10.6374 seconds.
Running test... 50000000 words, and 749995242 total characters, were built in 10.6445 seconds.
Running test... 50000000 words, and 749988379 total characters, were built in 10.6543 seconds.
Running test... 50000000 words, and 749969532 total characters, were built in 10.6722 seconds.
...

EZL Software - code timing plot

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

Примечания:

  1. даже удаление неиспользуемого член Printer класс дает разные контрольные результаты-когда это происходит, устраняет (или уменьшает до незначительных пропорций) разницу между двумя контрольными показателями выше.
  2. это не кажется проблемой при компиляции с g++ (на Ubuntu). Хотя, я не могу сказать это окончательно; мои тесты с Ubuntu были в виртуальной машине на той же машине Windows, где виртуальная машина, возможно, не имела доступа ко всем ресурсам и улучшениям процессора.
  3. я использую Visual Studio Community 2017 (Версия 15.7.4)
    • версия компилятора: 19.14.26431
    • все тесты и результаты релиз Build, 64-бит
  4. система: Win10, i7-6700K @ 4.00 Ггц, 32 ГБ ОЗУ

4 ответов


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

см.https://agner.org/optimize/, и ссылки производительности в тег x86 wiki. Но, честно говоря, я не думаю, что здесь есть какое-то полезное объяснение, другое чем то, что вы обнаружили, что ваш цикл чувствителен к эффектам выравнивания, либо от интерфейсного, либо от предсказания ветви. Это означает, что даже идентичный машинный код при разных выравниваниях в вашей основной программе может иметь разную производительность.

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

вы можете использовать счетчики производительности HW для измерения скорости неправильного толкования ветвей, чтобы понять, объясняет ли это, почему одна версия медленнее другой. или если есть какая-то другой front-end эффект.

но, к сожалению, вы мало что можете сделать; тривиальные различия источников, если они влияют на asm вообще, изменят выравнивание для всего.

иногда вы можете перепроектировать вещи, чтобы быть менее чувствительными к прогнозированию ветвей, заменив ветви безветвным кодом. например, всегда генерируйте 16 байтов случайных букв и усекайте их до случайной длины. (Некоторое ветвление по размеру При копировании, вероятно, неизбежно, если создание 16-байт std::string и затем усечение может быть без ветвей.)

вы можете ускорить это с помощью SIMD, например, использовать векторизованный PRNG, как С SSE2 или AVX2 xorshift+ для генерации 16 байт случайных букв одновременно. (эффективно получая равномерное 0..Распределение 25 с упакованными байтовыми операциями может быть сложным, но, возможно, тот же метод, что и 0..9 распределение я генерировать 1GiB случайных цифр ASCII, разделенных пробелами в ~0.03 секунды на 3.9 GHz Skylake были бы полезны. Однако он не идеально равномерно распределен, потому что 65536% 10 имеет остаток (например, 65536/25), но вы, вероятно, можете изменить качество и скорость компромисса и все равно работать быстро.)


сравнение выходных данных компилятора из двух версий

asm для обеих версий внутреннего цикла в runtest функции по существу являются идентичными, по крайней мере, если выходные данные ASM компилятора мы видим в проводнике компилятора Godbolt соответствует тому, что вы фактически получаете в исполняемом файле от MSVC. (В отличие от gcc/clang, его вывод asm не обязательно может быть собран в рабочий объектный файл.) Если ваша реальная сборка выпуска выполняет оптимизацию времени связи, которая может встроить некоторый библиотечный код, она может сделать разные варианты оптимизации в конечном исполняемом файле.

я #ifdef так что я мог бы использовать -DUSE_RL иметь два выхода MSVC 2017 это построило один и тот же источник разными способами и передало эти выходы asm в область различий. (панель diff находится внизу в грязном макете, который я связал; нажмите полноэкранное поле на нем, чтобы показать только это.)

единственными различиями во всей функции являются:

  • заказ и регистрация выбор для нескольких инструкций, таких как mov edx, DWORD PTR _tls_index и mov QWORD PTR run_length$GSCopy$[rbp-121], rcx в верхней части функции, которая выполняется только один раз. (Но не в размере кода, поэтому они не будут влиять выравнивание позже). Это не должно влиять на более поздний код, и они в конечном итоге вносят те же изменения в архитектурное состояние, просто используя другой Scratch reg, который больше не используется.
  • макет стека (положение локальных переменных относительно RBP). Но все смещения находятся под +127, поэтому они все еще могут использовать [rbp + disp8] режим адресации.
  • другой код-gen от фактического источника разница:

          mov     rdx, QWORD PTR word_list$[rbp-113]
          sub     rdx, QWORD PTR word_list$[rbp-121]  ; word_list.size() = end - start 
          ...
          sar     rdx, 5               ; >> 5   arithmetic right shift
    

    и

          mov     rdx, rsi             ; copy run_length from another register
    

    и нет, только эти инструкции не могут объяснить разницу в скорости. Они запускаются только один раз за интервал времени, до некоторого ввода-вывода

  • дополнительно npad 7 для выравнивания перед целевой ветвью в нижней части функции (после call _Xtime_get_ticks), после вышеуказанной разницы в кода.

есть большой блок красных / зеленых различий, но это только от различной нумерации меток, за исключением этих трех инструкций в начале функции.

но до runtest, the word_list.size() версия включает в себя код ??$?6_K@@YAAEAVPrinter@@AEAV0@$QEA_K@Z PROC функции который нигде не отображается для версии с помощью run_length. (C++ name-mangling превращает типы в фанковые символы в именах asm функций.) Это делает что-то для class Printer.

вы сказали удаление неиспользуемого std::string filename от Printer удалена разница в коде. Ну, эта функция, вероятно, уходит с этим изменением. IDK почему MSVC решил испустить его вообще, не говоря уже только в одной версии против другой.

наверное g++ -O3 не имеет этой разницы в коде, и поэтому вы не видите разницы. (Предполагая, что ваша виртуальная машина является аппаратной виртуализацией, машинный код, созданный g++, все еще работает на ЦП. Получение новой страницы памяти из ОС может занять немного времени дольше в виртуальной машине, но основное время, проведенное в цикле, вероятно, находится в пользовательском пространстве в этом коде.)


кстати, gcc предупреждает

<source>:72:24: warning: comparison of integer expressions of different signedness: 'int' and 'size_t' {aka 'long unsigned int'} [-Wsign-compare]

     for (auto i = 0; i < run_length; i++) {
                      ~~^~~~~~~~~~~~

я не смотрел внимательно на выход asm, чтобы увидеть, привело ли это к худшему коду с gcc или MSVC, или если это будет небезопасно, если вы передадите большие входы.


Я столкнулся с аналогичной ситуацией, незначительные изменения в коде оказывали значительное влияние на время выполнения. После преобразования кода в сборку для управления местоположением кода я обнаружил значительную разницу на процессоре Intel 3770K 3.5 ghz, в зависимости от того, где вызовы и жесткие циклы расположены в памяти. Наиболее существенной разницей, которую я нашел, была разница во времени 36.5%, упомянутая в этом вопросе, который я опубликовал, первоначально об использовании индексированного ветвления в осенний код против тугой петли. Еще более странно, что это зависело от комбинации местоположений, как отмечено в комментариях в коде сборки (возможно, конфликт в кэше инструкций?), с временем версии цикла в диапазоне от 1.465 секунд до 2.000 секунд, идентичный код, с единственной разницей nops между функциями, используемыми для выравнивания кода к определенным границам.

индексированные ветви накладные расходы на X86 64-битном режиме

Я не знаю, если другие процессоры будет испытывать такую большую разницу в производительности из-за местоположения кода.


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

printer << /*run_length*/ word_list.size() << " words, and "

и

printer << run_length /* word_list.size() */ << " words, and "

для записи я создавал x64 в сообществе Visual Studio 2017, у меня нет возможности создавать x86, так как я удалил цепочку инструментов x86 и библиотеки (наряду с некоторыми ненужными ARM вещи), чтобы получить гигабайт или около того места.

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

более медленная версия:

Running test... 50000000 words, and 749981638 total characters, were built in 16.3966 seconds.
Running test... 50000000 words, and 750037396 total characters, were built in 15.9712 seconds.
Running test... 50000000 words, and 749999562 total characters, were built in 16.0094 seconds.
Running test... 50000000 words, and 749990566 total characters, were built in 15.8863 seconds.
Running test... 50000000 words, and 749998381 total characters, were built in 15.8728 seconds.
Running test... 50000000 words, and 749997199 total characters, were built in 15.8799 seconds.

более быстрая версия:

Running test... 50000000 words, and 750000053 total characters, were built in 15.3437 seconds.
Running test... 50000000 words, and 750014937 total characters, were built in 15.4479 seconds.
Running test... 50000000 words, and 750054238 total characters, were built in 15.2631 seconds.
Running test... 50000000 words, and 750012691 total characters, were built in 15.5289 seconds.
Running test... 50000000 words, and 750013435 total characters, were built in 15.3742 seconds.
Running test... 50000000 words, and 749969960 total characters, were built in 15.3682 seconds.

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

вот странный. Список слов.size () " версия имеет это для итерации основного внешнего цикла:

    for (auto i = 0; i < run_length; i++)
00007FF7C77D2CF9  inc         r13d
00007FF7C77D2CFC  mov         dword ptr [rbp-79h],r13d
00007FF7C77D2D00  movsxd      rax,r13d
00007FF7C77D2D03  cmp         rax,qword ptr [rbp-31h]
00007FF7C77D2D07  mov         r14d,0FFFFFFFFh
00007FF7C77D2D0D  lea         rcx,[word_sz_generator (07FF7C77D70F0h)]
00007FF7C77D2D14  jb          runtest+130h (07FF7C77D2B40h)
    int64_t execution_time_usec = timer(); // stop timer

в то время как версия "run_length" делает это:

    for (auto i = 0; i < run_length; i++)
00007FF7C77D270B  inc         r13d
00007FF7C77D270E  mov         dword ptr [rbp-79h],r13d
00007FF7C77D2712  movsxd      rax,r13d
00007FF7C77D2715  mov         r14,qword ptr [rbp-31h]
00007FF7C77D2719  cmp         rax,r14
00007FF7C77D271C  mov         edi,0FFFFFFFFh
00007FF7C77D2721  lea         rcx,[word_sz_generator (07FF7C77D9820h)]
00007FF7C77D2728  jb          runtest2+130h (07FF7C77D2550h)
    int64_t execution_time_usec = timer(); // stop timer

обратите внимание, как более быстрая версия явно загружается [rbp-31h] на r14 прежде чем сравнивать его rax. Предположительно, чтобы использовать его позже. И он тогда ставит 0FFFFFFFFh на edi. Между тем более медленная версия напрямую сравнивает rax в память, а затем загружает ту же константу в r14d.

достаточно, чтобы создать разницу в производительности 3%? Очевидно, так.

TL; DR различия есть. Я в полной растерянности.


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

Я бы предложил отметить все под солнцем как "летучие" и посмотреть, если вы все еще получаете спорадические результаты.

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

надеюсь, что это помогает. Столкнулся с аналогичной проблемой бенчмаркинг микроконтроллера PIC32 несколько лет назад. Летучие спасает жизни.