Я протестировал следующие два способа заполнения вектора 100 тысячами элементов:

#include <iostream>
#include <vector>
#include <chrono>

using std::cout;
using std::endl;
using std::vector;
using std::chrono::high_resolution_clock;
using std::chrono::duration_cast;

int main()
{
    const int n = 100'000;

    cout << "Range constructor: " << endl;
    high_resolution_clock::time_point t0 = high_resolution_clock::now();

    int nums10[n];
    for (int i = 0; i < n; ++i) {
        nums10[i] = i;
    }
    vector<int> nums11(nums10, nums10 + n);

    high_resolution_clock::time_point t1 = high_resolution_clock::now();
    cout << "Duration: " << duration_cast<std::chrono::microseconds>(t1 - t0).count() << endl;


    cout << "Fill constructor: " << endl;
    t0 = high_resolution_clock::now();
    
    vector<int> nums1(n);
    for (int i = 0; i < n; ++i) {
        nums1[i] = i;
    }

    t1 = high_resolution_clock::now();
    cout << "Duration: " << duration_cast<std::chrono::microseconds>(t1 - t0).count() << endl;;
}

В моем случае конструктор диапазона работает почти в 10 раз быстрее (600 микросекунд против ~ 5000 микросекунд).

Почему здесь вообще может быть разница в производительности? Насколько я понимаю, существует равное количество операций присваивания. С помощью конструктора диапазона массиву присваивается 100 000 элементов, а затем все они копируются в вектор.

Разве это не должно быть идентично конструктору заполнения, в котором 100 000 элементов сначала инициализируются по умолчанию равным 0, а затем всем им присваиваются их «реальные» значения в цикле for?

0
J. Grohmann 9 Апр 2021 в 04:58

1 ответ

Лучший ответ

Вот скомпилированный код Godbolt с gcc -O0.

В первом тесте:

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

  • конструктор диапазона - это единственный вызов предварительно скомпилированного конструктора в библиотеке (строка 69). Можно предположить, что библиотечная функция была скомпилирована с агрессивной оптимизацией; вероятно, он вызывает сильно оптимизированный memcpy в рукописной сборке. Так что это, вероятно, так быстро, как могло бы быть.

Во втором тесте:

  • Конструктор заливки - это вызов библиотеки (строка 113). Опять же, это предположительно настолько быстро, насколько это возможно (вероятно, вызов оптимизированного вручную memset).

  • Ваш цикл для заполнения вектора (строки 118–130) генерирует вызов функции std::vector<int>::operator[] на каждой итерации (строка 126). Несмотря на то, что сам по себе operator[], вероятно, довольно быстр, поскольку он был предварительно скомпилирован, накладные расходы на вызов функции каждый раз, включая код для перезагрузки регистров с ее аргументами, являются убийственными. Если вы компилировали с оптимизацией, этот вызов можно было бы встроить; все эти накладные расходы исчезнут, и у вас снова будет только одно хранилище в памяти на итерацию. Но вы не оптимизируете, так что знаете? Производительность сильно не оптимальна.

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

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

2
Nate Eldredge 9 Апр 2021 в 02:50