簡體   English   中英

由於標准容器中元素的默認初始化導致性能下降

[英]Performance degradation due to default initialisation of elements in standard containers

(是的,我知道有一個問題幾乎相同的標題,但得到的答復是不能令人滿意的,見下文)

編輯抱歉,原始問題沒有使用編譯器優化。 現在已經修復了這個問題,但是為了避免瑣碎的優化並且更接近我的實際用例,測試已經分成兩個編譯單元。

當涉及性能關鍵的應用程序時, 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 這不再符合標准,但對我的測試用例非常有效(詳見下面我自己的答案和這個問題 )。

使用g ++ 4.5,通過使用生成器直接構造,我可以實現從v0(1.0s到0.8s)的運行時間減少約20%,而v1從0.95s減少到0.8s:

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;
}

好的,這是我問自這個問題后我學到的東西。

Q1有沒有合法的方法可以使用標准的庫容器來提供后面這些時間? )在某種程度上是肯定的 ,如Mark和Evgeny的回答所示。 std::vector的構造函數提供生成器的方法省略了默認構造。

Q2有沒有合法的方法來使用標准的庫容器,它會在多線程情況下提供后面這些時間? ,我不這么認為。 原因在於,在構造時, 任何符合標准的容器必須初始化其元素 ,以確保對元素析構函數的調用(在破壞或調整容器大小時)是良好的形式。 由於std庫容器不支持在構造元素時使用多線程,因此Q1的技巧無法復制,因此我們不能忽略默認構造。

因此,如果我們想要使用C ++進行高性能計算 ,那么在管理大量數據時,我們的選擇會受到一些限制。 我們可以

1聲明一個容器對象,並且在同一個編譯單元中,當編譯器希望優化構造時的初始化時,立即填充它(並發);

2采用new[]delete[]甚至malloc()free() ,當所有的內存管理和后一種情況下構建元素是我們的責任,而我們對C ++標准庫的潛在用法非常有限。

3欺騙std::vector不使用自定義的unitialised_allocator初始化其元素,該自定義unitialised_allocator了默認構造。 按照Jared Hoberock想法 ,這樣的分配器可能看起來像這樣(另見這里 ):

// 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

當作弊和“合法”版本之間沒有任何區別時。

boost::transformed

對於單線程版本,您可以使用boost::transformed 它有:

返回范圍類別:rng的范圍類別。

這意味着,如果您將Random Access Range賦予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));

現場演示

#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;

}

在這種情況下,我實際上會建議滾動自己的容器或尋找替代方案,因為我看到它的方式,你的固有問題不是標准容器的默認構造元素。 試圖將容量可變的容器用於可以在施工時確定容量的容器。

沒有標准庫不必要地默認構造元素的實例。 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的容器。 這就是我在你的案例中所建議的,因為如果默認構造在填充構造函數上下文中浪費不足以使替代reservepush_backemplace_back同樣不足,那么即使是一個容器處理它的容量也是可能的。而作為變量的大小是一個不可忽視的開銷,在這一點上,你更有理由去尋找別的東西,包括從上面的概念中推銷你自己的東西。

標准庫非常有效,因為它在蘋果與蘋果的比較中難以匹配,但在這種情況下,您的需求需要橙子而不是蘋果。 在這種情況下,直接接觸橙子往往變得更容易,而不是試圖將蘋果變成橙色。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM