Постановка задачи


Нас интересует скорость различных стандартных инструментов C++ для выполнения однотипных операций над большим количеством элементов (циклы, алгоритмы STL, итераторы, указатели и т.д.). Для упрощения будем считать исходной задачу вычисления суммы большого количества целых чисел. (Ссылка для тех, кто не любит читать, а любит смотреть.)

Prepare


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

#include <iostream>
#include <sys/time.h>
#include <iomanip>

using namespace std;

const int COUNT = 1000 * 1000 * 1000;

int main () {
       struct timespec tm1, tm2;
       clock_gettime(CLOCK_REALTIME, &tm1);

 // Our code to estimate

       clock_gettime(CLOCK_REALTIME, &tm2);

       double t1 = 1000.0 * tm1.tv_sec + tm1.tv_nsec / (1000.0 * 1000);
       double t2 = 1000.0 * tm2.tv_sec + tm2.tv_nsec / (1000.0 * 1000);
       cout << "t=" << setprecision(5) << t2 -t1 << " ms" << endl;
       return 0;
};


Не очень точно, но зато просто и понятно.

При последовательном переборе элементов в STL самым быстрым контейнером является вектор, так как его элементы располагаются последовательно. Так что создадим вектор размером COUNT и заполним его (почти) случайными целыми значениями:


#include <vector>
#include <algorithm>
…
int RandomNumber () {
       static int s = 0;
       return (s++ % 100);
}
int main () {
       vector<int> vec(COUNT);
       generate(vec.begin(), vec.end(), RandomNumber);
       ...
       // Our code to estimate


Accumulate


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

#include <numeric>
       …
       // Our code to estimate
       accumulate(vec.begin(), vec.end(), 0, [](int sum, int i) {
               return sum + i;
       });


Компилируем и запускаем:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 349.29 mseconds

Не мало…

for_each


Попробуем for_each?

// Our code to estimate
int sum = 0;
for_each(vec.begin(), vec.end(), [&sum](int i) {
       sum += i;
});

Смотрим:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 417.64 mseconds

Интересно, что если использовать объект с состоянием, получится чуть быстрее: передача по ссылке в лямбду чуток нас тормозит:

class Sum {
       int sum;
public:
       Sum() : sum(0) {}
       inline void operator()(int i) {
               sum += i;
       }
       inline operator int() {
               return sum;
       }
};
…
       // Our code to estimate
       for_each(vec.begin(), vec.end(), Sum());


Барабанная дробь…

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 371.64 mseconds

Еще можно сделать лямбду с состоянием и получить результат, сравнимый с accumulate, но игра не стоит свеч: результат все равно нас не устроит.

for


Вернемся к старым добрым циклам:

// Our code to estimate
int sum = 0;
for(auto i = vec.begin(); i != vec.end(); i++) {
       sum += *i;
};

Ну и что?

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 520.66 mseconds

Ну, это понятно, почему, меняем немного код:

auto end = vec.end();
for(auto i = vec.begin(); i != end; i++) {

Снова компилируем и запускаем:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 403.68 mseconds

Уже лучше! Еще мы знаем, что префиксный инкремент чуть быстрее чем постфиксный:

for(auto i = vec.begin(); i != end; ++i) {

Это потому, что в операции постфиксного инткремента используется временный объект + копирование.

Компилируем и запускаем:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 327.05 mseconds

Лучший результат на сегодня! Но еще не вечер…
Почему цикл оказался чуть быстрее? В основном потому, что тут нет вызова функций, а также нет лишних копирований объекта (в нашем случае — целочисленного) суммы.

iterator or not


Откажемся от итераторов вообще:

for(int i = 0; i < COUNT; ++i) {
   sum += vec[i];
};

Компилируем и запускаем:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 110.52 mseconds

Вот это уже — результат.

Тут мы выиграли сразу на двух операциях — инкремент инта быстрее, чем оператор инкремента у итератора, а оператор разыменовывания медленнее, чем доступ по индексу.

Arrays


А что будет, если мы используем обычный массив? Точнее, будем брать элементы по смещению указателя, а не по индексу (замена вектора на массив бессмыслена — вектор хранит свои данные в массиве):

int *array = &vec[0];
for(int i = 0; i < COUNT; ++i) {
       sum += array[i];
};

И оно того стоило:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 81.219 mseconds

Доступ по указателю намного быстрее!
А если итерировать сразу по указателю, без индексов, выйдет значительно быстрее:

auto end = &vec[vec.size () - 1];
for(int *i = &vec[0]; i != end; ++i) {
   sum += *i;
};

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 24.494 mseconds


Хардкор


Что еще мы можем сделать с циклом? Точно — «зарегистрировать»:

register int *array = &vec[0];
register int sum = 0;
for(register int i = 0; i != COUNT; ++i) {
       sum += array[i];
};

И что мы получаем?

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 10.456 mseconds

Результат радует, однако следует помнить, что злоупотребление модификатором register сводит его на нет: компилятор перестает обращать на него внимание когда регистров не хватает, а их всегда не хватает) Так что вышеприведенный результат представляет скорее академический интерес, чем практический.

Можно по-эксперементировать с алгоритмами, например, такое изменение:

sum += array[i] + array[++i];

Ускорит наш код еще в двое:

$ g++ -std=c++11 main.cpp -o test && ./test 
$ duration 4.8562 mseconds

UPDATE: и приведет к неопределенному поведению! Спасибо alexac за замечание.
За рамки статьи также вышли оптимизации компилятора, мы их не используем. Но это — тема отдельной статьи

Вывод


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

image

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

Комментарии (43)


  1. AMDmi3
    10.06.2015 18:26
    +33

    Сравнивать быстродействие кода без оптимизаций? Ну ок.


    1. VioletGiraffe
      10.06.2015 21:45
      +1

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


      1. lexdevel
        11.06.2015 10:56

        обход вектора с помощью итератора заметно медленнее, чем по индексу

        Скорее всего это связано с тем, что Вы использовали постинкремент, а надо — преинкремент. Пруф.


        1. VioletGiraffe
          11.06.2015 11:48

          Не-а, всегда пишу прединкремент. После проделанного вчера эксперимента (мой пост ниже) я понял, что дело всего-навсего было в том, что я не догадался предвычислить end() до начала цикла, а size() — догадался. Это единственный фактор, насколько я могу судить по результатам исследования.

          А вот разницы между +it и it++ как раз и нет в этом конкретном тесте.


    1. xiWera
      11.06.2015 00:47

      Ага, автор узнает много нового когда скомпилит все варианты с -O2 и посмотрит на дизасм :)


  1. mickey99
    10.06.2015 18:34
    +1

    Повторите пожалуйста те же тесты с _SECURE_SCL=0 и _HAS_ITERATOR_DEBUGGING=0


    1. AMDmi3
      10.06.2015 18:55
      +4

      А какое отношение к gcc имеют VC'шные костыли?


      1. mickey99
        10.06.2015 19:55
        +3

        Вы правы, никакого. Просто автоматическая реакция на «STL тормозит».


  1. Gorthauer87
    10.06.2015 18:38
    +20

    Только вот во всех этих сравнениях есть одна большая проблема: gcc по умолчанию не включает оптимизации, а если собирать с -O2, то всё становится совсем иначе:

    Берем уровень -O3 и сравниваем accumulate с самым хардкорным вариантом:

    aleksey@aleksey ~/d/p/bench> ./test 
    t=0.00024414 ms
    aleksey@aleksey ~/d/p/bench> ./test2 
    t=0.00024414 ms
    


    А если не видно разницы, то зачем платить больше?


    1. GRascm
      10.06.2015 18:39
      +1

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


      1. Gorthauer87
        10.06.2015 18:41

        Кстати, clang тоже дают точно такую же скорость, хотя на -O1 он уже сливает.


    1. Moxa
      10.06.2015 20:48
      +1

      что-то подозрительно, у меня такое же значение выдало с пустым кодом…


      1. GRascm
        10.06.2015 21:00

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


        1. Moxa
          10.06.2015 21:05
          +5

          а сумма где-нибудь используется? скорее она и не считалась, оптимизатор просто удалил ненужный цикл


  1. QtRoS
    10.06.2015 19:27
    -11

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


    1. AWE64
      10.06.2015 21:00
      +1

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


  1. kashey
    10.06.2015 19:51
    +4

    Можно еще префетча немного добавить и суммировать «рядком» в несколько переменных — быть может векторизация прилетит.
    В свое время (ну лет 15 назад, когда DDR память появилась) AMD с Интелом выпускали километры документации о том как же быстро пройтись по массиву и получить весь заявленный в рекламе bandwidth. Ой не простое это было дело.
    С тех пор подросло целое поколение…


  1. Zordhauer
    10.06.2015 20:07
    -6

    Мне кажется, для хорошего программиста должно быть очевидно, что за любой «сахар» (улучшение читабельности, возможность повторого использования, универсальность, дополнительную абстракцию и т.п.) требуется заплатить как раз-таки скоростью выполнения.
    Хочешь скорости работы программы — пиши в машинных кодах, а если хочешь скорости своей работы (и тех, кто будет потом править твой код), то пиши на максимально высокоуровневом языке =)

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


    1. MichaelBorisov
      10.06.2015 20:14
      +14

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

      Некоторый «сахар» C++ достается именно что бесплатно. При условии, что компилятор разберется, как перевести этот сахар в эффективный машинный код. Таких случаев немало, и особенно их немало с STL. В крайнем случае «платой за сахар» будет некоторое замедление компиляции.

      И написание программы на ассемблере ничуть не уступает (по скорости ее работы) написанию ее в машинных кодах. Поэтому в машинных кодах никто не пишет еще с 50х-60х гг прошлого века.


      1. Zordhauer
        10.06.2015 20:34
        -5

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


        1. kmu1990
          10.06.2015 20:46
          +10

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

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

          иначе эта опция был бы включена по умолчанию

          Я думал, что оптимизации не включены по-умолчанию, чтобы сделать отладку проще и компиляцию быстрее (gcc docs), или другими словами сделать процесс разработки более удобным, разве нет?


  1. MichaelBorisov
    10.06.2015 20:10
    +4

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

    Поначалу я удивился, почему помогает ключевое слово «register», ведь даже в VC5.0 компилятор разобрался бы, как оптимизировать такой простой цикл, и без «register». Сам бы на регистрах что надо разместил. Тем более регистров процессора более чем достаточно для такого простого цикла.

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


  1. VioletGiraffe
    10.06.2015 22:14
    +1

    Тот же тест, винда, MSVC 2013 (toolset v120) x86, vector(200 * 1000 * 1000). Релиз + /O2 (дефолтные релизные настройки):

    accumulate: 46 ms
    for_each: 118 ms
    for(int i: vec): 118 ms // Обидно, да?
    for (auto i = vec.begin(); i != vec.end(); i++): 47 ms
    for (auto i = vec.begin(); i != end; ++i): 46 ms
    for (size_t i = 0; i < vec.size(); ++i) sum += vec[i];: 118 ms
    for (size_t i = 0; i < size; ++i) sum += vec[i];: 47 ms // Ага, понятно, почему range-based for медленный: компилятор, как и в этом случае, не кэширует" vec.size() и вычисляет его каждый раз.
    int *array = &vec[0]; for(size_t i = 0; i < size; ++i) sum += array[i];: 47 ms
    auto end = &vec[vec.size() — 1]; for (int *i = &vec[0]; i != end; ++i) sum += *i;: 47 ms
    register: 47 ms // Ч. т. д.
    sum += array[i] + array[++i]: 65 ms // Интересно…


    1. VioletGiraffe
      10.06.2015 22:20
      +1

      P. S. Я расстроен, что компилятор не умеет оптимизировать вызов const-метода std::vector::size (ну или end). Неужели это так сложно — определить, что на векторе после generate не вызываются никакие не-const методы? И почему бы в векторе не сделать размер переменной, чтоб его просто вернуть, а не вычислять каждый раз?


      1. MichaelBorisov
        12.06.2015 13:28
        -1

        Я думаю, тут в первую очередь косяк разработчиков STL (P.J. Plauger). Реализация vector::size вычисляет разность между двумя переменными (this->_Mylast — this->Myfirst). Вероятно, время тратится на доступ к этим указателям в памяти.

        ассемблерный код получается следующим (VS2013, Release, x86)

        mov eax,dword ptr[esp+0Ch]
        mov esi,dword ptr[esp+8]
        sub eax,esi
        sar eax,2


        Во как. 4 команды, 2 доступа к памяти, и кроме вычитания еще имеем сдвиг. Сейчас попробую сделать свой «вектор» и посмотреть, что накомпилирует компилятор при простом возврате члена данных класса.


        1. MichaelBorisov
          12.06.2015 13:48
          +1

          Впрочем, к кэшированию все это не относится. Только что посмотрел, как компилятор компилирует цикл for(size_t i=0; i<v.size(); i++) a+=v[i]. Там что-то невообразимое получилось! Очень сложные конструкции, использование SSE и регистров xmm, явные попытки частично размотать цикл. Еще надо поразбираться.


      1. MichaelBorisov
        12.06.2015 13:39
        -1

        Только что попробовал сделать свой «вектор», у которого размер хранился бы в переменной-члене класса. Компилятор прекрасно кэширует значение этой переменной на регистре. Вот пример кода:

        #include <iostream>
        #include <vector>
        
        class kektor
        {
        public:
            kektor(size_t nsize) : m_size(nsize) {}
        
            size_t size(void) const throw() { return m_size; }
        private:
            size_t m_size;
        };
        
        void print_kek(const kektor& v)
        {
            for (size_t i = 0; i < v.size(); i++)
            {
                std::cout << i<<'\n';
            }
        }
        
        
        void main(void)
        {
            std::cout << "Hello World!\n";
            size_t sz;
            std::cin >> sz;
            kektor v(sz);
            print_kek(v);
        }
        

        Ассемблерный код для функции print_kek:
        xor         edi,edi
        test        esi,esi
        je          endloop
        loop:
        push        edi 
        call        std::basic_ostream<char,std::char_traits<char> >::operator<<
        mov         ecx,eax
        call        std::operator<<<std::char_traits<char> >
        inc         edi
        cmp         edi,esi
        jb          loop
        endloop: ...
        

        Так что да. Неприятно. Недосмотр авторов STL. Оптимизации компилятора доверяй, но проверяй.


      1. kmu1990
        12.06.2015 13:40
        +1

        А почему вы уверены, что в строке sum += vec[i] вызывается const метод? Кажется, если сам vec не const, то из const и не const метода будет выбран как раз последний.


      1. DaylightIsBurning
        12.06.2015 23:08

        Это не так просто: нужно знать, что значение size() не меняется по ходу выполнения цикла, что далеко не факт, независимо от const, может size() текущее время возвращает — откуда компилятору знать. Короче значение size() может косвенно зависеть от операций в цикле и компилятору не так то просто убедиться в обратном.


        1. VioletGiraffe
          12.06.2015 23:24

          Да, вы правы, const тут не помогает.


        1. 0xd34df00d
          15.06.2015 15:39

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

          Ждём атрибут [[pure]] или рассчитываем на сознательность библиотек, поставляемых с компилятором.


  1. Lol4t0
    10.06.2015 22:33
    +1

    Поиграем в игру «найдите 5 отличий»

    Возьмем код
    #include <vector>
    #include <algorithm>
    using namespace std;
    
    int foo (vector<int> vec) {
           return accumulate(vec.begin(), vec.end(), 0, [](int sum, int i) {
                   return sum + i;
           });
    }
    
    int bar (vector<int> vec) {
           const int COUNT = vec.size();
           register int *array = &vec[0];
           register int sum = 0;
           for(register int i = 0; i != COUNT; ++i) {
               sum += array[i];
           };
           return sum;
    }
    


  1. ZyXI
    10.06.2015 22:39
    +2

    Странно, что вас ругали за отсутствие оптимизаций, но пропустили CLOCK_REALTIME. Эти часы нельзя использовать для любых измерений производительности: документация явно говорит, что они могут изменяться. Специально для измерения производительности есть CLOCK_MONOTONIC_RAW (CLOCK_MONOTONIC для не?linux) и CLOCK_PROCESS_CPUTIME_ID. На простоту кода изменение константы на правильную не влияет никак.


    1. ferus Автор
      12.06.2015 22:27

      Отличное замечание, вы безусловно правы


  1. alexac
    11.06.2015 00:36
    +6

    Мне одному кажется, что последний пример

    sum += array[i] + array[++i];
    


    Это явное UB? Я не удивляюсь, что там прирост скорости вдвое, там компилятор имел право вообще пол системы снести во время трансляции.

    Упрощенный пример и предупреждение: codepad.org/U2jK5eIo


    1. StrangerInRed
      11.06.2015 09:02
      -3

      Какое UB? ++i? Ну мог выйти за границы массива, а так мы берем просто два элемента подряд и перескакиваем через один.


      1. kmu1990
        11.06.2015 09:37
        +5

        Нет, имеется ввиду, что i изменяется и читается в одном выражении. Например, порядок вычисления array[i] и array[++i] не определен, например, он может сначала посчитать array[++i] и тогда программа дважды обратится к одному и тому же элементу массива, что не правильно.

        А вообще вот, что пишет стандарт:

        If a side effect on a scalar object is unsequenced relative to either another side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined.

        В данном случае, как я понимаю, сайд эффект от ++i «unsequenced» с использованием i в array[i].


        1. Singerofthefall
          11.06.2015 10:25
          +1

          Да, вы совершенно правы, так как (§1.9/15)

          Except where noted, evaluations of operands of individual operators and of subexpressions of individual expressions are unsequenced


    1. ferus Автор
      12.06.2015 22:28

      Да, вы правы, позор на мои седины,
      добавил в статью UPDATE, спасибо!


  1. andy_p
    11.06.2015 12:14
    +1

    auto end = &vec[vec.size () — 1];
    for(int *i = &vec[0]; i != end; ++i) {
    sum += *i;
    };

    А последний элемент не суммируем?


    1. ferus Автор
      12.06.2015 22:29
      -2

      да ну его


  1. DaylightIsBurning
    11.06.2015 17:11

    Решил проверить сам и заодно узнать, насколько хорошо работают оптимизации. Во-первых не сошлись времена даже близко — у меня «регистровая» версия выдаёт 660 ms (а не 10 как у автора). Во-вторых оптимизации всё уравнивают, что бы оптимизации цикл совсем не выкинули — добавил вывод суммы в консоль.

    g++ --version
    cat /proc/cpuinfo | grep "model name" | head -n1
    
    g++ -std=c++11 test_for.cpp -o test_for
    g++ -std=c++11 -O3 test_for.cpp -o test_forO3
    
    g++ -std=c++11 test_acc.cpp -o test_acc
    g++ -std=c++11 -O3 test_acc.cpp -o test_accO3
    
    g++ -std=c++11 test_reg.cpp -o test_reg
    g++ -std=c++11 -O3 test_reg.cpp -o test_regO3
    
    echo "No O; for,acc,reg:"
    ./test_for
    ./test_acc
    ./test_reg
    
    echo "-O3; for,acc,reg:"
    ./test_forO3
    ./test_accO3
    ./test_regO3
    

    Результат:
    g++ (Ubuntu 4.9.2-0ubuntu1~14.04) 4.9.2
    Copyright (C) 2014 Free Software Foundation, Inc.
    This is free software; see the source for copying conditions.  There is NO
    warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
    
    model name      : Intel(R) Core(TM) i7-4930K CPU @ 3.40GHz
    No O; for,acc,reg:
    t=11341 ms, sum=-2039607552
    t=8141.1 ms, sum=-2039607552
    t=660.89 ms, sum=-2039607552
    -O3; for,acc,reg:
    t=231.37 ms, sum=-2039607552
    t=231.31 ms, sum=-2039607552
    t=232.29 ms, sum=-2039607552
    

    test_reg.cpp
    #include <iostream>
    #include <sys/time.h>
    #include <iomanip>
    #include <vector>
    #include <algorithm>
    #include <numeric>
    
    using namespace std;
    
    const int COUNT = 1000 * 1000 * 1000;
    int RandomNumber () {
        static int s = 0;
        return (s++ % 100);
    }
    
    int main () {
        vector<int> vec(COUNT);
        generate(vec.begin(), vec.end(), RandomNumber);
        
        struct timespec tm1, tm2;
        clock_gettime(CLOCK_REALTIME, &tm1);
        
        register int *array = &vec[0];
        register int sum = 0;
        for(register int i = 0; i != COUNT; ++i) {
    	sum += array[i];
        };
        
        clock_gettime(CLOCK_REALTIME, &tm2);
        
        double t1 = 1000.0 * tm1.tv_sec + tm1.tv_nsec / (1000.0 * 1000);
        double t2 = 1000.0 * tm2.tv_sec + tm2.tv_nsec / (1000.0 * 1000);
        cout << "t=" << setprecision(5) << t2 -t1 << " ms, sum=" <<sum<< endl;
        return 0;
    };
    


    1. ferus Автор
      12.06.2015 22:30