Деградация производительности из-за инициализации по умолчанию элементов в стандартных контейнерах

(Да, я знаю, что есть question с почти одинаковым названием, но ответ был неудовлетворительным, см. ниже)

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

Тот факт, что конструктор std::vector<> имеет линейную сложность, является досадой, когда дело доходит до критически важных приложений. Рассмотрим этот простой код

// compilation unit 1:
void set_v0(type*x, size_t n)
{
  for(size_t i=0; i<n; ++i)
    x[i] = simple_function(i);
}

// compilation unit 2:
std::vector<type> x(n);                     // default initialisation is wasteful
set_v0(x.data(),n);                         // over-writes initial values

когда значительное количество времени теряется путем построения x. Общим способом, описанным в этом вопросе, является просто резервирование хранилища и использование push_back() для заполнения данных:

// compilation unit 1:
void set_v1(std::vector<type>&x, size_t n)
{
  x.reserve(n);
  for(size_t i=0; i<n; ++i)
    x.push_back(simple_function(i));
}

// compilation unit 2:
std::vector<type> x(); x.reserve(n);        // no initialisation
set_v1(x,n);                                // using push_back()

Однако, как указано моим комментарием, push_back() по сути медленный, делая этот второй подход на самом деле медленнее первого для достаточно простых конструктивных объектов, таких как size_t s, когда для

simple_function = [](size_t i) { return i; };

Я получаю следующие тайминги (используя gcc 4.8 с -O3; clang 3.2 произвел ~ 10% медленный код)

timing vector::vector(n) + set_v0();
n=10000 time: 3.9e-05 sec
n=100000 time: 0.00037 sec
n=1000000 time: 0.003678 sec
n=10000000 time: 0.03565 sec
n=100000000 time: 0.373275 sec

timing vector::vector() + vector::reserve(n) + set_v1();
n=10000 time: 1.9e-05 sec
n=100000 time: 0.00018 sec
n=1000000 time: 0.00177 sec
n=10000000 time: 0.020829 sec
n=100000000 time: 0.435393 sec

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

// compilation unit 2
std::vector<type> x; x.reserve(n);          // no initialisation
set_v0(x,n);                                // error: write beyond end of vector
                                            // note: vector::size() == 0

когда мы получаем

timing vector::vector + vector::reserve(n) + set_v0();          (CHEATING)
n=10000 time: 8e-06 sec
n=100000 time: 7.2e-05 sec
n=1000000 time: 0.000776 sec
n=10000000 time: 0.01119 sec
n=100000000 time: 0.298024 sec

Итак, мой первый вопрос: Есть ли законный способ использования контейнера стандартной библиотеки, который даст эти последние тайминги? Или мне нужно прибегать к управлению памятью?

Теперь, что я действительно хочу, нужно использовать многопоточность для заполнения контейнера. Наивный код (с использованием openMP в этом примере для простоты, который на данный момент исключает clang)

// compilation unit 1
void set_v0(type*x, size_t n)
{
#pragma omp for                       // only difference to set_v0() from above 
  for(size_t i=0; i<n; ++i)
    x[i] = simple_function(i);
}

// compilation unit 2:
std::vector<type> x(n);               // default initialisation not mutli-threaded
#pragma omp parallel
set_v0(x,n);                          // over-writes initial values in parallel

теперь страдает тем, что инициализация по умолчанию всех элементов не является многопоточной, что приводит к потенциально серьезной деградации производительности. Ниже приведены тайминги для set_omp_v0() и эквивалентного метода обмана (с использованием моего чипа macbook intel i7 с 4 ядрами, 8 гиперпотоков):

timing std::vector::vector(n) + omp parallel set_v0()
n=10000 time: 0.000389 sec
n=100000 time: 0.000226 sec
n=1000000 time: 0.001406 sec
n=10000000 time: 0.019833 sec
n=100000000 time: 0.35531 sec

timing vector::vector + vector::reserve(n) + omp parallel set_v0(); (CHEATING)
n=10000 time: 0.000222 sec
n=100000 time: 0.000243 sec
n=1000000 time: 0.000793 sec
n=10000000 time: 0.008952 sec
n=100000000 time: 0.089619 sec

Обратите внимание, что чит-версия в ~ 3,3 раза быстрее, чем серийная чит-версия, примерно так, как ожидалось, но стандартной версии нет.

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

PS. Я нашел этот вопрос, где std::vector обманут, чтобы избежать инициализации по умолчанию, предоставив ему uninitialized_allocator, Это уже не стандартное соответствие, но очень хорошо работает для моего тестового примера (см. Мой собственный ответ ниже и этот вопрос для деталей).

Ответ 1

Хорошо, вот что я узнал, задав этот вопрос.

Q1 (Есть ли какой-либо законный способ использования стандартного библиотечного контейнера, который даст эти последние тайминги?) Да в некоторой степени, как показано в ответах Mark и Евгений. Метод предоставления генератора конструктору std::vector выравнивает конструкцию по умолчанию.

Q2 (Есть ли законный способ использования стандартного контейнера библиотеки, который даст эти последние тайминги в многопоточных ситуациях?) Нет, я не думаю так. Причина в том, что при построении любой стандартный совместимый контейнер должен инициализировать свои элементы, уже для того, чтобы гарантировать, что вызов деструктора элемента (при уничтожении или изменении размера контейнера) хорошо сформирован. Поскольку контейнеры библиотеки std не поддерживают использование многопоточности при конструировании своих элементов, трюк Q1 не может быть реплицирован здесь, поэтому мы не можем исключить конструкцию по умолчанию.

Таким образом, если мы хотим использовать С++ для высокопроизводительных вычислений, наши варианты несколько ограничены, когда дело доходит до управления большими объемами данных. Мы можем

1 объявить объект-контейнер и в том же компиляторе немедленно заполнить его (одновременно), когда компилятор надеется оптимизировать инициализацию при построении;

2 прибегают к new[] и delete[] или даже malloc() и free(), когда все управление памятью и, в последнем случае, построение элементов - наша ответственность и наша потенциальное использование стандартной библиотеки С++ очень ограничено.

3 обманите a std::vector, чтобы не инициализировать его элементы, используя пользовательский unitialised_allocator, который возвращает конструкцию по умолчанию. Следуя идеям Джареду Хоброку, такой распределитель мог бы выглядеть так (см. Также здесь):

// based on a design by Jared Hoberock
// edited (Walter) 10-May-2013, 23-Apr-2014
template<typename T, typename base_allocator = std::allocator<T> >
struct uninitialised_allocator
  : base_allocator
{
  static_assert(std::is_same<T,typename base_allocator::value_type>::value,
                "allocator::value_type mismatch");

  template<typename U>
  using base_t =
    typename std::allocator_traits<base_allocator>::template rebind_alloc<U>;

  // rebind to base_t<U> for all U!=T: we won't leave other types uninitialised!
  template<typename U>
  struct rebind
  {
    typedef typename
    std::conditional<std::is_same<T,U>::value,
                     uninitialised_allocator, base_t<U> >::type other; 
  }

  // elide trivial default construction of objects of type T only
  template<typename U>
  typename std::enable_if<std::is_same<T,U>::value && 
                          std::is_trivially_default_constructible<U>::value>::type
  construct(U*) {}

  // elide trivial default destruction of objects of type T only
  template<typename U>
  typename std::enable_if<std::is_same<T,U>::value && 
                          std::is_trivially_destructible<U>::value>::type
  destroy(U*) {}

  // forward everything else to the base
  using base_allocator::construct;
  using base_allocator::destroy;
};

Тогда шаблон unitialised_vector<> может быть определен следующим образом:

template<typename T, typename base_allocator = std::allocator<T>>
using uninitialised_vector = std::vector<T,uninitialised_allocator<T,base_allocator>>;

и мы можем использовать почти все стандартные функции библиотеки. Хотя следует сказать, что uninitialised_allocator и, следовательно, по смыслу unitialised_vector не являются стандартными, потому что его элементы не построены по умолчанию (например, vector<int> не будет иметь все 0 после построения).

При использовании этого инструмента для моей небольшой тестовой проблемы я получаю отличные результаты:

timing vector::vector(n) + set_v0();
n=10000 time: 3.7e-05 sec
n=100000 time: 0.000334 sec
n=1000000 time: 0.002926 sec
n=10000000 time: 0.028649 sec
n=100000000 time: 0.293433 sec

timing vector::vector() + vector::reserve() + set_v1();
n=10000 time: 2e-05 sec
n=100000 time: 0.000178 sec
n=1000000 time: 0.001781 sec
n=10000000 time: 0.020922 sec
n=100000000 time: 0.428243 sec

timing vector::vector() + vector::reserve() + set_v0();
n=10000 time: 9e-06 sec
n=100000 time: 7.3e-05 sec
n=1000000 time: 0.000821 sec
n=10000000 time: 0.011685 sec
n=100000000 time: 0.291055 sec

timing vector::vector(n) + omp parllel set_v0();
n=10000 time: 0.00044 sec
n=100000 time: 0.000183 sec
n=1000000 time: 0.000793 sec
n=10000000 time: 0.00892 sec
n=100000000 time: 0.088051 sec

timing vector::vector() + vector::reserve() + omp parallel set_v0();
n=10000 time: 0.000192 sec
n=100000 time: 0.000202 sec
n=1000000 time: 0.00067 sec
n=10000000 time: 0.008596 sec
n=100000000 time: 0.088045 sec

когда нет никакой разницы между изменчивыми и "законными" версиями.

Ответ 2

С g++ 4.5 я смог реализовать приблизительно 20% -ное сокращение времени выполнения от v0 (1.0s до 0.8s) и немного меньше от 0.95s до 0.8s для v1, используя генератор для прямой сборки:

struct Generator : public std::iterator<std::forward_iterator_tag, int>
{
    explicit Generator(int start) : value_(start) { }
    void operator++() { ++value_; }
    int operator*() const { return value_; }

    bool operator!=(Generator other) const { return value_ != other.value_; }

    int value_;
};

int main()
{
    const int n = 100000000;
    std::vector<int> v(Generator(0), Generator(n));

    return 0;
}

Ответ 3

boost::transformed

Для однопоточной версии вы можете использовать boost::transformed. Он имеет:

Категория возвращаемого диапазона: категория диапазона rng.

Это означает, что если вы дадите Random Access Range to boost::transformed, он вернет Random Access Range, что позволит конструктору vector предварительно выделить требуемый объем памяти.

Вы можете использовать его следующим образом:

const auto &gen = irange(0,1<<10) | transformed([](int x)
{
    return exp(Value{x});
});
vector<Value> v(begin(gen),end(gen));

LIVE DEMO

#define BOOST_RESULT_OF_USE_DECLTYPE 
#include <boost/range/adaptor/transformed.hpp>
#include <boost/container/vector.hpp>
#include <boost/range/irange.hpp>
#include <boost/progress.hpp>
#include <boost/range.hpp>
#include <iterator>
#include <iostream>
#include <ostream>
#include <string>
#include <vector>
#include <array>


using namespace std;
using namespace boost;
using namespace adaptors;

#define let const auto&

template<typename T>
void dazzle_optimizer(T &t)
{
    auto volatile dummy = &t; (void)dummy;
}

// _______________________________________ //

using Value = array<int,1 << 16>;
using Vector = container::vector<Value>;

let transformer = [](int x)
{
    return Value{{x}};
};
let indicies = irange(0,1<<10);

// _______________________________________ //

void random_access()
{
    let gen = indicies | transformed(transformer);
    Vector v(boost::begin(gen), boost::end(gen));
    dazzle_optimizer(v);
}

template<bool reserve>
void single_pass()
{
    Vector v;
    if(reserve)
        v.reserve(size(indicies));
    for(let i : indicies)
        v.push_back(transformer(i));
    dazzle_optimizer(v);
}

void cheating()
{
    Vector v;
    v.reserve(size(indicies));
    for(let i : indicies)
        v[i]=transformer(i);
    dazzle_optimizer(v);
}

// _______________________________________ //

int main()
{
    struct
    {
        const char *name;
        void (*fun)();
    } const tests [] =
    {
        {"single_pass, no reserve",&single_pass<false>},
        {"single_pass, reserve",&single_pass<true>},
        {"cheating reserve",&cheating},
        {"random_access",&random_access}
    };
    for(let i : irange(0,3))
        for(let test : tests)
            progress_timer(), // LWS does not support auto_cpu_timer
                (void)i,
                test.fun(),
                cout << test.name << endl;

}

Ответ 4

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

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

T* mem = static_cast<T*>(malloc(num * sizeof(T)));
for (int j=0; j < num; ++j)
     new (mem + j) T(...); // meaningfully construct T
...
for (int j=0; j < num; ++j)
     mem[j].~T();         // destroy T
free(mem);

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

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